v0.3.2 a514042

headless_test.go RawHistoryFolder

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}

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 2

Tags 9

↑↓ moveenter openesc close