v0.3.0 da0887c

module_test.go RawHistoryFolder

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}

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 5

Tags 4

↑↓ moveenter openesc close