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}