v0.4.0 5f1ad00

headless_test.go RawHistoryFolder

1package neferclient_test
2
3import (
4	"context"
5	"os"
6	"os/exec"
7	"path/filepath"
8	"strings"
9	"syscall"
10	"testing"
11	"time"
12
13	"github.com/bnema/go-wayland-bindings/client/wayland"
14	"github.com/stretchr/testify/require"
15
16	"github.com/bnema/neferclient"
17)
18
19// headlessEnvDrop lists the environment prefixes the child compositor must not
20// inherit: anything that could attach it to, or make it act on, the caller's
21// session.
22var headlessEnvDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
23	"XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
24	"XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
25
26func cleanEnv(in []string) []string {
27	out := make([]string, 0, len(in))
28next:
29	for _, e := range in {
30		for _, p := range headlessEnvDrop {
31			if strings.HasPrefix(e, p) {
32				continue next
33			}
34		}
35		out = append(out, e)
36	}
37	return out
38}
39
40// startHeadless runs NeferWL (NEFERCLIENT_HEADLESS) in an isolated runtime
41// directory and returns its socket path. The test is skipped when the variable
42// is unset.
43func startHeadless(t *testing.T) string {
44	t.Helper()
45	bin := os.Getenv("NEFERCLIENT_HEADLESS")
46	if bin == "" {
47		t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary")
48	}
49	bin, err := filepath.Abs(bin)
50	require.NoError(t, err)
51	root := t.TempDir()
52	dirs := map[string]string{}
53	for _, name := range []string{"run", "config", "data", "state"} {
54		dirs[name] = filepath.Join(root, name)
55		require.NoError(t, os.Mkdir(dirs[name], 0o700))
56	}
57	logf, err := os.Create(filepath.Join(root, "neferwl.log"))
58	require.NoError(t, err)
59	t.Cleanup(func() { _ = logf.Close() })
60	cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480", "--timeout", "30s")
61	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
62	cmd.Env = append(cleanEnv(os.Environ()),
63		"XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"],
64		"XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"])
65	cmd.Stdout, cmd.Stderr = logf, logf
66	require.NoError(t, cmd.Start())
67	exited := make(chan struct{})
68	go func() { _ = cmd.Wait(); close(exited) }()
69	t.Cleanup(func() {
70		_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
71		<-exited
72	})
73	deadline := time.Now().Add(15 * time.Second)
74	for {
75		paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))
76		for _, p := range paths {
77			if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
78				return p
79			}
80		}
81		select {
82		case <-exited:
83			b, _ := os.ReadFile(logf.Name())
84			t.Fatalf("neferwl exited before serving:\n%s", b)
85		case <-time.After(5 * time.Millisecond):
86		}
87		if time.Now().After(deadline) {
88			b, _ := os.ReadFile(logf.Name())
89			t.Fatalf("headless socket timeout:\n%s", b)
90		}
91	}
92}
93
94func TestHeadlessConnect(t *testing.T) {
95	socket := startHeadless(t)
96	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
97	defer cancel()
98	var c *neferclient.Conn
99	var err error
100	for { // the socket file exists before listen(); a refused dial is transient
101		if c, err = neferclient.Connect(ctx, socket); err == nil || ctx.Err() != nil {
102			break
103		}
104		time.Sleep(5 * time.Millisecond)
105	}
106	require.NoError(t, err)
107	defer c.Close()
108
109	// The compositor creates its virtual output just after the socket appears,
110	// so it may arrive through OutputAdded instead of being there at Connect.
111	for len(c.Outputs()) == 0 {
112		select {
113		case <-c.Wake():
114			require.NoError(t, c.Dispatch(nil))
115		case <-ctx.Done():
116			t.Fatal("no output announced")
117		}
118	}
119	outs := c.Outputs()
120	for _, o := range outs {
121		t.Logf("output %d name=%q scale=%d %dx%d", o.Global, o.Name, o.Scale, o.Width, o.Height)
122		require.Positive(t, o.Width)
123		require.Positive(t, o.Height)
124		require.NotZero(t, o.Scale)
125	}
126	require.NoError(t, c.Roundtrip())
127
128	comp, v, err := c.Bind[*wayland.Compositor](wayland.CompositorInterface, 4, wayland.NewCompositor)
129	require.NoError(t, err)
130	require.NotZero(t, v)
131	surface, err := comp.CreateSurface()
132	require.NoError(t, err)
133	require.NoError(t, surface.Destroy())
134	require.NoError(t, c.Roundtrip())
135	require.NoError(t, c.Dispatch(nil))
136	require.NoError(t, c.Close())
137}

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 1

Tags 4

↑↓ moveenter openesc close