v0.4.0 b6be3a8

server_test.go RawHistoryFolder

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

Tags 10

↑↓ moveenter openesc close