1package visual
2
3import (
4 "context"
5 "io"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "syscall"
11 "testing"
12 "time"
13
14 "github.com/bnema/neferafk/internal/ports"
15 "github.com/bnema/nefergui"
16 "github.com/stretchr/testify/require"
17)
18
19// The headless tests run the real lock and fade loops against a NeferWL
20// compositor started with two virtual outputs in an isolated runtime
21// directory. They are skipped unless NEFERAFK_HEADLESS names its binary, and
22// they only ever connect to that compositor: WAYLAND_DISPLAY is set to its
23// absolute socket path. Never point them at a real session.
24
25var headlessEnvDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
26 "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
27 "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
28
29// startHeadless runs NeferWL, points WAYLAND_DISPLAY at it for this test and
30// returns its input script writer.
31func startHeadless(t *testing.T) io.Writer {
32 t.Helper()
33 bin := os.Getenv("NEFERAFK_HEADLESS")
34 if bin == "" {
35 t.Skip("set NEFERAFK_HEADLESS to a NeferWL binary")
36 }
37 bin, err := filepath.Abs(bin)
38 require.NoError(t, err)
39 root := t.TempDir()
40 env := []string{}
41 for _, e := range os.Environ() {
42 keep := true
43 for _, p := range headlessEnvDrop {
44 keep = keep && !strings.HasPrefix(e, p)
45 }
46 if keep {
47 env = append(env, e)
48 }
49 }
50 run := filepath.Join(root, "run")
51 for _, name := range []string{"run", "config", "data", "state"} {
52 require.NoError(t, os.Mkdir(filepath.Join(root, name), 0o700))
53 env = append(env, "XDG_"+map[string]string{"run": "RUNTIME_DIR", "config": "CONFIG_HOME", "data": "DATA_HOME", "state": "STATE_HOME"}[name]+"="+filepath.Join(root, name))
54 }
55 logf, err := os.Create(filepath.Join(root, "neferwl.log"))
56 require.NoError(t, err)
57 t.Cleanup(func() { _ = logf.Close() })
58 cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480,320x240",
59 "--timeout", "60s", "--input", "-")
60 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
61 cmd.Env, cmd.Stdout, cmd.Stderr = env, logf, logf
62 stdin, err := cmd.StdinPipe()
63 require.NoError(t, err)
64 require.NoError(t, cmd.Start())
65 exited := make(chan struct{})
66 go func() { _ = cmd.Wait(); close(exited) }()
67 t.Cleanup(func() {
68 _ = stdin.Close()
69 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
70 <-exited
71 })
72 for deadline := time.Now().Add(15 * time.Second); ; {
73 paths, _ := filepath.Glob(filepath.Join(run, "wayland-*"))
74 for _, p := range paths {
75 if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
76 require.True(t, filepath.IsAbs(p))
77 t.Setenv("WAYLAND_DISPLAY", p) // absolute: XDG_RUNTIME_DIR is not used
78 return stdin
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
94// TestHeadlessLockSubmitsTypedSecretAndUnlocksOnRequest covers both outputs,
95// waits for locked and types into the lock. Escape and Ctrl+Shift+U clear the
96// field, a secret too long for an authentication frame is never submitted,
97// Enter submits exactly the typed bytes, and only the Unlock request ends the
98// lock.
99func TestHeadlessLockSubmitsTypedSecretAndUnlocksOnRequest(t *testing.T) {
100 input := startHeadless(t)
101 locked := make(chan struct{}, 1)
102 submits := make(chan []byte, 4)
103 outputErrs := make(chan string, 4)
104 unlock := make(chan struct{}, 1)
105 status := make(chan lockStatus, 1)
106 cfg := lockConfig{
107 View: func(f *nefergui.Frame, st lockState) { lockView(f, st, promptPassword, time.Now()) },
108 OnLocked: func() { locked <- struct{}{} },
109 OnSubmit: func(secret []byte) { submits <- append([]byte(nil), secret...) },
110 OnOutputError: func(output string, _ error) { outputErrs <- output },
111 OnError: func(err error) { outputErrs <- err.Error() },
112 Unlock: unlock,
113 Status: status,
114 }
115 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
116 done := make(chan error, 1)
117 go func() { done <- guiLocker{}.RunLock(ctx, cfg) }()
118 // Runs before the compositor is killed (cleanups run in reverse order), so
119 // the lock goroutine has returned before the test ends, even on failure.
120 t.Cleanup(func() {
121 cancel()
122 select {
123 case <-done:
124 case <-time.After(10 * time.Second):
125 }
126 })
127
128 recv(t, locked)
129 long := strings.Repeat("x", ports.AuthMaxSecret+1)
130 _, err := io.WriteString(input, "sleep 200ms\ntype zz\nkey Escape\ntype yy\nkey Ctrl+Shift+u\n"+
131 "key Return\n"+ // empty after the clears: nothing to submit
132 "type "+long+"\nkey Return\n"+ // too long: refused
133 "type abc\nsleep 100ms\nkey BackSpace\ntype d\nsleep 100ms\nkey Return\n")
134 require.NoError(t, err)
135 require.Equal(t, []byte("abd"), recv(t, submits), "the first submission is the last secret")
136 status <- lockFailed // a status change only redraws
137 select {
138 case err := <-done:
139 t.Fatalf("lock ended without an unlock request: %v", err)
140 case <-time.After(300 * time.Millisecond):
141 }
142 unlock <- struct{}{}
143 require.NoError(t, recv(t, done))
144 require.Empty(t, submits)
145 require.Empty(t, outputErrs)
146}
147
148// TestHeadlessFadeEndsAfterReveal fades every output to black, then back,
149// and checks that the run ends by itself once the reveal completed.
150func TestHeadlessFadeEndsAfterReveal(t *testing.T) {
151 startHeadless(t)
152 m := newFadeModel()
153 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
154 done := make(chan error, 1)
155 go func() { done <- guiFader{log: testLog()}.RunFade(ctx, m) }()
156 t.Cleanup(func() { // before the compositor is killed, even on failure
157 cancel()
158 select {
159 case <-done:
160 case <-time.After(10 * time.Second):
161 }
162 })
163 m.send(fadeCmd{black: true, dur: 100 * time.Millisecond})
164 time.Sleep(300 * time.Millisecond)
165 select {
166 case err := <-done:
167 t.Fatalf("fade ended while black: %v", err)
168 default:
169 }
170 m.send(fadeCmd{black: false, dur: 100 * time.Millisecond})
171 require.NoError(t, recv(t, done))
172}