v0.1.0 d9218d6

server_test.go RawHistoryFolder

1package wayland
2
3import (
4	"bytes"
5	"context"
6	"github.com/bnema/neferwl/internal/ports"
7	"os"
8	"os/exec"
9	"path/filepath"
10	"slices"
11	"strings"
12	"sync"
13	"testing"
14	"time"
15
16	"github.com/bnema/neferwl/internal/logging"
17)
18
19func TestWaylandInfo(t *testing.T) {
20	tool, err := exec.LookPath("wayland-info")
21	if err != nil {
22		t.Skip("wayland-info not installed")
23	}
24	dir := t.TempDir()
25	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
26	if err != nil {
27		t.Fatal(err)
28	}
29	ctx, cancel := context.WithCancel(context.Background())
30	done := make(chan error, 1)
31	go func() { done <- s.Run(ctx) }()
32	defer func() {
33		cancel()
34		select {
35		case err := <-done:
36			if err != nil {
37				t.Errorf("Run: %v", err)
38			}
39		case <-time.After(2 * time.Second):
40			t.Error("Run did not stop")
41		}
42		if _, err := os.Stat(filepath.Join(dir, s.SocketName())); !os.IsNotExist(err) {
43			t.Errorf("socket remains: %v", err)
44		}
45	}()
46	cmdCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
47	defer stop()
48	cmd := exec.CommandContext(cmdCtx, tool)
49	cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName())
50	out, err := cmd.CombinedOutput()
51	if err != nil {
52		t.Fatalf("wayland-info: %v\n%s", err, out)
53	}
54	for _, want := range []string{"wl_compositor", "wl_shm", "wl_output", "wl_seat", "wl_data_device_manager", "HEADLESS-1"} {
55		if !strings.Contains(string(out), want) {
56			t.Errorf("missing %q in:\n%s", want, out)
57		}
58	}
59}
60
61func TestWestonFrames(t *testing.T) {
62	tool, err := exec.LookPath("weston-simple-shm")
63	if err != nil {
64		t.Skip("weston-simple-shm not installed")
65	}
66	dir := t.TempDir()
67	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
68	if err != nil {
69		t.Fatal(err)
70	}
71	ctx, cancel := context.WithCancel(context.Background())
72	done := make(chan error, 1)
73	go func() { done <- s.Run(ctx) }()
74	t.Cleanup(func() {
75		cancel()
76		select {
77		case err := <-done:
78			if err != nil {
79				t.Errorf("Run: %v", err)
80			}
81		case <-time.After(2 * time.Second):
82			t.Error("Run hung")
83		}
84	})
85	clientCtx, stop := context.WithCancel(context.Background())
86	cmd := exec.CommandContext(clientCtx, tool)
87	cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName())
88	var stderr strings.Builder
89	cmd.Stderr = &stderr
90	if err := cmd.Start(); err != nil {
91		stop()
92		t.Fatal(err)
93	}
94	exited := make(chan error, 1)
95	go func() { exited <- cmd.Wait() }()
96	t.Cleanup(func() {
97		stop()
98		select {
99		case <-exited:
100		case <-time.After(2 * time.Second):
101			t.Error("client did not exit")
102		}
103	})
104	deadline := time.After(2 * time.Second)
105	tick := time.NewTicker(25 * time.Millisecond)
106	defer tick.Stop()
107	for {
108		select {
109		case err := <-exited:
110			t.Fatalf("client exited: %v: %s", err, stderr.String())
111		case <-deadline:
112			t.Fatalf("only %d frames; stderr: %s", s.frames, stderr.String())
113		case <-tick.C:
114			var n uint64
115			s.display.Do(func() { n = s.frames })
116			if n >= 10 {
117				return
118			}
119		}
120	}
121}
122
123// lifecycleServer starts a display with buffered command and event channels.
124func lifecycleServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
125	t.Helper()
126	dir := t.TempDir()
127	events := make(chan ports.ClientEvent, 16)
128	commands := make(chan ports.ClientCommand, 16)
129	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
130	if err != nil {
131		t.Fatal(err)
132	}
133	ctx, cancel := context.WithCancel(context.Background())
134	done := make(chan error, 1)
135	go func() { done <- s.Run(ctx) }()
136	t.Cleanup(func() {
137		cancel()
138		select {
139		case err := <-done:
140			if err != nil {
141				t.Errorf("Run: %v", err)
142			}
143		case <-time.After(2 * time.Second):
144			t.Error("Run hung")
145		}
146	})
147	return s, events, commands, dir
148}
149
150func lifecycleClient(t *testing.T, tool string, s *Server, dir string, extra []string, args ...string) (*exec.Cmd, <-chan error, *lockedBuffer) {
151	t.Helper()
152	ctx, cancel := context.WithCancel(context.Background())
153	cmd := exec.CommandContext(ctx, tool, args...)
154	cmd.Env = append(append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName()), extra...)
155	output := &lockedBuffer{}
156	cmd.Stderr = output
157	if err := cmd.Start(); err != nil {
158		cancel()
159		t.Fatal(err)
160	}
161	done := make(chan error, 1)
162	finished := make(chan struct{})
163	go func() { done <- cmd.Wait(); close(finished) }()
164	t.Cleanup(func() {
165		cancel()
166		_ = cmd.Process.Kill()
167		select {
168		case <-finished:
169		case <-time.After(2 * time.Second):
170			t.Error("client hung")
171		}
172	})
173	return cmd, done, output
174}
175
176type lockedBuffer struct {
177	mu sync.Mutex
178	b  bytes.Buffer
179}
180
181func (b *lockedBuffer) Write(p []byte) (int, error) {
182	b.mu.Lock()
183	defer b.mu.Unlock()
184	return b.b.Write(p)
185}
186func (b *lockedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.b.String() }
187
188func waitEvent(t *testing.T, events <-chan ports.ClientEvent, duration time.Duration) ports.ClientEvent {
189	t.Helper()
190	select {
191	case ev := <-events:
192		return ev
193	case <-time.After(duration):
194		t.Fatal("event timeout")
195		return nil
196	}
197}
198func mapped(t *testing.T, events <-chan ports.ClientEvent, duration time.Duration) ports.WindowMapped {
199	t.Helper()
200	ev := waitEvent(t, events, duration)
201	w, ok := ev.(ports.WindowMapped)
202	if !ok {
203		t.Fatalf("expected mapped, got %T", ev)
204	}
205	return w
206}
207func unmapped(t *testing.T, events <-chan ports.ClientEvent, id ports.WindowID) {
208	t.Helper()
209	ev := waitEvent(t, events, 3*time.Second)
210	w, ok := ev.(ports.WindowUnmapped)
211	if !ok || w.ID != id {
212		t.Fatalf("expected unmapped %d, got %#v", id, ev)
213	}
214}
215
216func TestWindowClose(t *testing.T) {
217	tool, err := exec.LookPath("weston-simple-shm")
218	if err != nil {
219		t.Skip("weston-simple-shm not installed")
220	}
221	s, events, commands, dir := lifecycleServer(t)
222	_, done, output := lifecycleClient(t, tool, s, dir, nil)
223	w := mapped(t, events, 5*time.Second)
224	commands <- ports.CloseWindow{ID: w.ID}
225	select {
226	case err := <-done:
227		if err != nil {
228			t.Errorf("client: %v: %s", err, output.String())
229		}
230	case <-time.After(5 * time.Second):
231		t.Fatal("client did not close")
232	}
233	unmapped(t, events, w.ID)
234}
235func TestWindowConfigure(t *testing.T) {
236	tool, err := exec.LookPath("weston-simple-shm")
237	if err != nil {
238		t.Skip("weston-simple-shm not installed")
239	}
240	s, events, commands, dir := lifecycleServer(t)
241	_, _, output := lifecycleClient(t, tool, s, dir, []string{"WAYLAND_DEBUG=client"})
242	w := mapped(t, events, 5*time.Second)
243	commands <- ports.ConfigureWindow{ID: w.ID, Width: 640, Height: 480, Activated: true}
244	deadline := time.After(3 * time.Second)
245	tick := time.NewTicker(10 * time.Millisecond)
246	defer tick.Stop()
247	for {
248		select {
249		case <-deadline:
250			t.Fatalf("missing configure(640, 480: %s", output.String())
251		case <-tick.C:
252			if strings.Contains(output.String(), "configure(640, 480") {
253				return
254			}
255		}
256	}
257}
258func TestWindowKilled(t *testing.T) {
259	tool, err := exec.LookPath("weston-simple-shm")
260	if err != nil {
261		t.Skip("weston-simple-shm not installed")
262	}
263	s, events, _, dir := lifecycleServer(t)
264	cmd, _, _ := lifecycleClient(t, tool, s, dir, nil)
265	w := mapped(t, events, 5*time.Second)
266	if err := cmd.Process.Kill(); err != nil {
267		t.Fatal(err)
268	}
269	unmapped(t, events, w.ID)
270}
271func TestFootWindow(t *testing.T) {
272	tool, err := exec.LookPath("foot")
273	if err != nil {
274		t.Skip("foot not installed")
275	}
276	s, events, _, dir := lifecycleServer(t)
277	_, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + t.TempDir(), "PATH=" + os.Getenv("PATH")}, "-c", "/dev/null", "sh", "-c", "sleep 3")
278	select {
279	case ev := <-events:
280		w, ok := ev.(ports.WindowMapped)
281		if !ok {
282			t.Fatalf("expected mapped, got %T", ev)
283		}
284		if w.AppID != "foot" {
285			t.Fatalf("app ID: %q", w.AppID)
286		}
287	case err := <-done:
288		t.Fatalf("foot exited: %v: %s", err, output.String())
289	case <-time.After(10 * time.Second):
290		t.Fatalf("foot did not map: %s", output.String())
291	}
292}
293
294func TestNewMissingRuntimeDirClosesDisplay(t *testing.T) {
295	before, err := os.ReadDir("/proc/self/fd")
296	if err != nil {
297		t.Skip(err)
298	}
299	dir := filepath.Join(t.TempDir(), "missing")
300	if _, err := New(Options{RuntimeDir: dir}, Channels{}, logging.For(context.Background(), "wayland")); err == nil {
301		t.Fatal("expected error")
302	}
303	after, err := os.ReadDir("/proc/self/fd")
304	if err != nil {
305		t.Fatal(err)
306	}
307	if len(after) != len(before) {
308		t.Fatalf("fd leak: %d -> %d", len(before), len(after))
309	}
310	if _, err := os.Stat(dir); !os.IsNotExist(err) {
311		t.Fatalf("unexpected directory: %v", err)
312	}
313}
314
315func TestUnbufferedEventsDoNotBlockDisplay(t *testing.T) {
316	tool, err := exec.LookPath("weston-simple-shm")
317	if err != nil {
318		t.Skip("weston-simple-shm not installed")
319	}
320	dir := t.TempDir()
321	events := make(chan ports.ClientEvent)
322	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events}, logging.For(context.Background(), "wayland"))
323	if err != nil {
324		t.Fatal(err)
325	}
326	ctx, cancel := context.WithCancel(context.Background())
327	done := make(chan error, 1)
328	go func() { done <- s.Run(ctx) }()
329	t.Cleanup(func() {
330		cancel()
331		select {
332		case err := <-done:
333			if err != nil {
334				t.Error(err)
335			}
336		case <-time.After(2 * time.Second):
337			t.Error("Run hung")
338		}
339	})
340	lifecycleClient(t, tool, s, dir, nil)
341	deadline := time.After(5 * time.Second)
342	for {
343		ready := make(chan bool, 1)
344		go func() { ready <- s.display.Do(func() {}) }()
345		select {
346		case ok := <-ready:
347			if !ok {
348				t.Fatal("display stopped")
349			}
350		case <-time.After(time.Second):
351			t.Fatal("display blocked on event")
352		}
353		mappedOnDisplay := false
354		if !s.display.Do(func() { mappedOnDisplay = len(s.windows) > 0 }) {
355			t.Fatal("display stopped")
356		}
357		if mappedOnDisplay {
358			break
359		}
360		select {
361		case <-deadline:
362			t.Fatal("no mapping event")
363		case <-time.After(10 * time.Millisecond):
364		}
365	}
366	mapped(t, events, time.Second)
367}
368
369func TestWestonContents(t *testing.T) {
370	tool, err := exec.LookPath("weston-simple-shm")
371	if err != nil {
372		t.Skip("weston-simple-shm not installed")
373	}
374	dir := t.TempDir()
375	events := make(chan ports.ClientEvent, 16)
376	contents := make(chan ports.SurfaceContent, 16)
377	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Contents: contents}, logging.For(context.Background(), "wayland"))
378	if err != nil {
379		t.Fatal(err)
380	}
381	ctx, cancel := context.WithCancel(context.Background())
382	done := make(chan error, 1)
383	go func() { done <- s.Run(ctx) }()
384	t.Cleanup(func() {
385		cancel()
386		select {
387		case err := <-done:
388			if err != nil {
389				t.Errorf("Run: %v", err)
390			}
391		case <-time.After(2 * time.Second):
392			t.Error("Run hung")
393		}
394	})
395	_, _, output := lifecycleClient(t, tool, s, dir, nil)
396	w := mapped(t, events, 5*time.Second)
397	first := time.After(5 * time.Second)
398	count := 0
399	for count < 2 {
400		select {
401		case c := <-contents:
402			if c.ID != w.ID || c.Width <= 0 || c.Height <= 0 || c.SHM == nil || c.SHM.Stride < c.Width*4 || c.SHM.File == nil {
403				t.Fatalf("invalid content: %+v", c)
404			}
405			// The pool holds the client's pixels: read them like a renderer.
406			px := make([]byte, c.SHM.Stride)
407			if _, err := c.SHM.File.ReadAt(px, int64(c.SHM.Offset+c.Height/2*c.SHM.Stride)); err != nil {
408				t.Fatal(err)
409			}
410			if !slices.ContainsFunc(px, func(b byte) bool { return b != 0 }) {
411				t.Fatal("empty pixels")
412			}
413			if count == 0 {
414				first = time.After(2 * time.Second)
415			}
416			count++
417		case <-first:
418			t.Fatalf("only %d contents: %s", count, output.String())
419		}
420	}
421}
422
423// testOutputs is the 1920x1080 output tests start with.
424var testOutputs = ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080}, Width: 1920, Height: 1080, Scale: 1}}

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 23

Tags 10

↑↓ moveenter openesc close