1package module
2
3import (
4 "bytes"
5 "context"
6 "github.com/bnema/neferbar/internal/racecheck"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13)
14
15func newTest() (*Module, chan struct{}) {
16 wake := make(chan struct{}, 1)
17 return New("t", Left, "true", wake, slog.New(slog.DiscardHandler)), wake
18}
19
20func TestReadFramesSplitsOnNewlineAndFormFeed(t *testing.T) {
21 m, _ := newTest()
22 m.readFrames(strings.NewReader("one\ntwo\fthree"))
23 // "three" has no terminator yet: the latest complete frame is "two".
24 got, changed, _ := m.Take(nil)
25 if !changed || string(got) != "two" {
26 t.Fatalf("got %q changed=%v, want latest complete frame %q", got, changed, "two")
27 }
28 if _, changed, _ = m.Take(got); changed {
29 t.Fatal("Take must clear the dirty flag")
30 }
31}
32
33func TestFrameLongerThanMaxIsTruncated(t *testing.T) {
34 m, _ := newTest()
35 m.readFrames(strings.NewReader(strings.Repeat("x", MaxFrame*3) + "\n"))
36 got, _, _ := m.Take(nil)
37 if len(got) != MaxFrame {
38 t.Fatalf("len = %d, want %d", len(got), MaxFrame)
39 }
40}
41
42func TestPublishWakesWithoutBlocking(t *testing.T) {
43 m, wake := newTest()
44 for i := 0; i < 1000; i++ { // nobody drains wake: must never block
45 m.publish([]byte("x"))
46 }
47 if len(wake) != 1 {
48 t.Fatalf("wake queue = %d, want 1", len(wake))
49 }
50}
51
52func TestRunStreamsAndRestarts(t *testing.T) {
53 wake := make(chan struct{}, 1)
54 m := New("t", Left, "printf 'hello\\n'", wake, slog.New(slog.DiscardHandler))
55 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
56 defer cancel()
57 go m.Run(ctx)
58 var seen int
59 deadline := time.After(4 * time.Second)
60 for seen < 2 { // the script exits at once; it must be restarted
61 select {
62 case <-wake:
63 if b, ch, failed := m.Take(nil); ch && !failed && bytes.Equal(b, []byte("hello")) {
64 seen++
65 }
66 case <-deadline:
67 t.Fatalf("saw %d frames, want 2 (restart)", seen)
68 }
69 }
70}
71
72func TestPublishAllocs(t *testing.T) {
73 if racecheck.Enabled {
74 t.Skip("the race detector allocates")
75 }
76 m, _ := newTest()
77 frame := []byte("\x1b[31mred\x1b[0m text")
78 dst := make([]byte, 0, MaxFrame)
79 if got := testing.AllocsPerRun(500, func() {
80 m.publish(frame)
81 dst, _, _ = m.Take(dst)
82 }); got > 0 {
83 t.Errorf("publish+Take allocates %.1f objects; want 0", got)
84 }
85}
86
87// Stopping a module must also end the processes the script started, and Wait
88// must return once they are gone.
89func TestStopEndsTheScriptsChildren(t *testing.T) {
90 marker := filepath.Join(t.TempDir(), "alive")
91 // A background loop in a subshell: it outlives "sh" unless the whole group is killed.
92 script := "(while :; do echo x > " + marker + "; sleep 0.05; done) & wait"
93 m := New("t", Left, script, make(chan struct{}, 1), slog.New(slog.DiscardHandler))
94 ctx, cancel := context.WithCancel(context.Background())
95 m.Start(ctx)
96 deadline := time.Now().Add(3 * time.Second)
97 for {
98 if _, err := os.Stat(marker); err == nil {
99 break
100 }
101 if time.Now().After(deadline) {
102 t.Fatal("the script never started")
103 }
104 time.Sleep(20 * time.Millisecond)
105 }
106 cancel()
107 m.Wait(5 * time.Second)
108 os.Remove(marker)
109 time.Sleep(300 * time.Millisecond)
110 if _, err := os.Stat(marker); err == nil {
111 t.Fatal("a child of the script is still running after Wait returned")
112 }
113}
114
115func TestScriptDir(t *testing.T) {
116 home, _ := os.UserHomeDir()
117 cwd, _ := os.Getwd()
118 for command, want := range map[string]string{
119 "/opt/bar/left.sh": "/opt/bar",
120 "/opt/bar/left.sh 60 30": "/opt/bar",
121 "~/.config/neferbar/a.sh": filepath.Join(home, ".config/neferbar"),
122 "examples/modules/clock.sh": filepath.Join(cwd, "examples/modules"),
123 "./local.sh": cwd,
124 "date": "",
125 "sh -c 'echo hi'": "",
126 "": "",
127 " ": "",
128 } {
129 if got := ScriptDir(command); got != want {
130 t.Errorf("ScriptDir(%q) = %q, want %q", command, got, want)
131 }
132 }
133}
134
135func TestSendReachesTheScript(t *testing.T) {
136 wake := make(chan struct{}, 1)
137 m := New("t", Left, `while read l; do printf '%s\n' "$l"; done`, wake, slog.New(slog.DiscardHandler))
138 m.Interactive = true
139 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
140 defer cancel()
141 m.Start(ctx)
142 defer m.Wait(5 * time.Second)
143 defer cancel()
144 deadline := time.After(5 * time.Second)
145 for {
146 // The script may start after the first Send: resend until it answers.
147 m.Send([]byte("hover 3"))
148 select {
149 case <-wake:
150 if b, ch, failed := m.Take(nil); ch && !failed && string(b) == "hover 3" {
151 return
152 }
153 case <-time.After(50 * time.Millisecond):
154 case <-deadline:
155 t.Fatal("the script never echoed the line")
156 }
157 }
158}
159
160func TestSendRefusals(t *testing.T) {
161 m, _ := newTest()
162 if m.Send([]byte("hover 1")) {
163 t.Fatal("a non-interactive module must refuse Send")
164 }
165 m.Interactive = true
166 if m.Send(make([]byte, 128)) {
167 t.Fatal("a line over 127 bytes must be refused")
168 }
169 if !m.Send(make([]byte, 127)) {
170 t.Fatal("a 127-byte line must be accepted")
171 }
172}
173
174func TestSendNeverBlocks(t *testing.T) {
175 m, _ := newTest()
176 m.Interactive = true
177 done := make(chan int)
178 go func() {
179 ok := 0
180 for i := 0; i < 33; i++ {
181 if m.Send([]byte("hover 1")) {
182 ok++
183 }
184 }
185 done <- ok
186 }()
187 select {
188 case ok := <-done:
189 if ok != 32 {
190 t.Fatalf("%d sends accepted, want 32 (the queue size)", ok)
191 }
192 case <-time.After(2 * time.Second):
193 t.Fatal("Send blocked on a full queue")
194 }
195}
196
197// A script that never reads its stdin must not hold the module back.
198func TestUnreadStdinDoesNotBlockStop(t *testing.T) {
199 m := New("t", Left, "echo up; sleep 30", make(chan struct{}, 1), slog.New(slog.DiscardHandler))
200 m.Interactive = true
201 ctx, cancel := context.WithCancel(context.Background())
202 m.Start(ctx)
203 for i := 0; i < 40; i++ {
204 m.Send([]byte(strings.Repeat("x", 100)))
205 time.Sleep(5 * time.Millisecond)
206 }
207 cancel()
208 start := time.Now()
209 m.Wait(5 * time.Second)
210 if time.Since(start) > 4*time.Second {
211 t.Fatal("stopping took too long")
212 }
213}
214
215func TestSendAllocs(t *testing.T) {
216 if racecheck.Enabled {
217 t.Skip("the race detector allocates")
218 }
219 m, _ := newTest()
220 m.Interactive = true
221 line := []byte("hover 12")
222 if got := testing.AllocsPerRun(100, func() {
223 m.Send(line)
224 <-m.input
225 }); got > 0 {
226 t.Errorf("Send allocates %.1f objects; want 0", got)
227 }
228}
229
230// A script that never reads its stdin blocks the writer once the pipe is
231// full; stop must still return, because closing the pipe ends the Write.
232func TestPumpInputStopUnblocksAWrite(t *testing.T) {
233 r, w, err := os.Pipe() // r is never read
234 if err != nil {
235 t.Fatal(err)
236 }
237 defer r.Close()
238 m, _ := newTest()
239 m.Interactive = true
240 stop := m.pumpInput(w)
241
242 // Push well over the 64 KiB a pipe holds, until the queue stays full: then
243 // the writer is stuck in Write.
244 line := []byte(strings.Repeat("x", 100))
245 sent := 0
246 deadline := time.Now().Add(5 * time.Second)
247 for full := 0; full < 50; {
248 if time.Now().After(deadline) {
249 t.Fatalf("the writer never blocked after %d lines", sent)
250 }
251 if m.Send(line) {
252 sent++
253 full = 0
254 } else {
255 full++
256 time.Sleep(time.Millisecond)
257 }
258 }
259 if sent*101 <= 64<<10 {
260 t.Fatalf("only %d bytes went in; the pipe was not full", sent*101)
261 }
262
263 done := make(chan struct{})
264 go func() { stop(); close(done) }()
265 select {
266 case <-done:
267 case <-time.After(time.Second):
268 t.Fatal("stop did not return: the blocked Write was not released")
269 }
270}
271
272func TestGenCountsStarts(t *testing.T) {
273 wake := make(chan struct{}, 1)
274 m := New("t", Left, "printf 'hi\\n'", wake, slog.New(slog.DiscardHandler))
275 if m.Gen() != 0 {
276 t.Fatalf("Gen before start = %d", m.Gen())
277 }
278 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
279 defer cancel()
280 go m.Run(ctx)
281 deadline := time.After(4 * time.Second)
282 for m.Gen() < 2 { // the script exits at once and is restarted
283 select {
284 case <-wake:
285 case <-time.After(20 * time.Millisecond):
286 case <-deadline:
287 t.Fatalf("Gen = %d, want at least 2 after a restart", m.Gen())
288 }
289 }
290 if allocs := testing.AllocsPerRun(100, func() { m.Gen() }); allocs != 0 {
291 t.Errorf("Gen allocates %v", allocs)
292 }
293}