detached 53d2224

test(examples): fix stderr race, wait for focus, check the secret never leaks

commit
53d222496a0ce1d7b3089f15035145ab412baa0d
author
brice · · 2026-10-01 19:23 UTC
parent
63e3cbe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +50 -9
M examples/headless_test.go +50 -9
@@ -10,7 +10,9 @@ import (
1010 "os"
1111 "os/exec"
1212 "path/filepath"
13+ "slices"
1314 "strings"
15+ "sync"
1416 "syscall"
1517 "testing"
1618 "time"
@@ -20,8 +22,9 @@ import (
2022 // headless in an isolated runtime directory, and are skipped unless
2123 // NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to
2224 // that nested compositor: the child environment is stripped of the caller's
23-// session variables, and the socket is checked to live in the temporary
24-// directory. Never point these tests at a real session.
25+// session variables, and the socket is checked to be absolute (an absolute
26+// WAYLAND_DISPLAY is how the example finds it). Never point these tests at a
27+// real session.
2528
2629 var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
2730 "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
@@ -48,6 +51,25 @@ func must(t *testing.T, err error, what ...any) {
4851 }
4952 }
5053
54+// syncBuffer is a bytes buffer safe for the copying goroutine of exec and the
55+// test goroutine.
56+type syncBuffer struct {
57+ mu sync.Mutex
58+ b strings.Builder
59+}
60+
61+func (s *syncBuffer) Write(p []byte) (int, error) {
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ return s.b.Write(p)
65+}
66+
67+func (s *syncBuffer) String() string {
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ return s.b.String()
71+}
72+
5173 type compositor struct {
5274 socket, shots string
5375 env []string
@@ -94,8 +116,8 @@ func startHeadless(t *testing.T) *compositor {
94116 paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))
95117 for _, p := range paths {
96118 if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
97- if !strings.HasPrefix(p, root) {
98- t.Fatalf("socket %s is outside the test directory", p)
119+ if !filepath.IsAbs(p) {
120+ t.Fatalf("socket %q is not absolute", p)
99121 }
100122 return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin}
101123 }
@@ -125,7 +147,8 @@ func build(t *testing.T, pkg string) string {
125147 // command prepares an example to run against c, and only against c.
126148 func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd {
127149 cmd := exec.CommandContext(ctx, exe, args...)
128- cmd.Env = append(c.env, "WAYLAND_DISPLAY="+c.socket)
150+ // slices.Concat copies: c.env is never aliased or modified.
151+ cmd.Env = slices.Concat(c.env, []string{"WAYLAND_DISPLAY=" + c.socket})
129152 return cmd
130153 }
131154
@@ -153,8 +176,8 @@ func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool {
153176 }
154177
155178 func TestHeadlessLayer(t *testing.T) {
156- c := startHeadless(t)
157179 exe := build(t, "./layer")
180+ c := startHeadless(t)
158181 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
159182 defer cancel()
160183 cmd := c.command(ctx, exe, "-frames", "20")
@@ -182,20 +205,31 @@ func TestHeadlessLayer(t *testing.T) {
182205 }
183206
184207 func TestHeadlessLock(t *testing.T) {
185- c := startHeadless(t)
186208 exe := build(t, "./lock")
209+ c := startHeadless(t)
187210 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
188211 defer cancel()
189212 cmd := c.command(ctx, exe)
190213 stdout, err := cmd.StdoutPipe()
191214 must(t, err)
192- var stderr strings.Builder
215+ var stderr syncBuffer
193216 cmd.Stderr = &stderr
194217 must(t, cmd.Start())
218+ // Wait runs once: here, after the program is done, or from the cleanup if
219+ // the test fails first (the context kill ends the process).
220+ var waitOnce sync.Once
221+ var waitErr error
222+ wait := func() error {
223+ waitOnce.Do(func() { waitErr = cmd.Wait() })
224+ return waitErr
225+ }
226+ t.Cleanup(func() { cancel(); _ = wait() })
227+ var all syncBuffer // everything the program printed on stdout
195228 lines := make(chan string, 8)
196229 go func() {
197230 sc := bufio.NewScanner(stdout)
198231 for sc.Scan() {
232+ _, _ = all.Write(append(sc.Bytes(), '\n'))
199233 lines <- sc.Text()
200234 }
201235 close(lines)
@@ -212,10 +246,17 @@ func TestHeadlessLock(t *testing.T) {
212246 }
213247 }
214248 expect("locked")
249+ expect("focused") // the keyboard reached a lock surface: typing is safe
215250 // Only the nested compositor receives this input; it types into the
216251 // password field (shown masked) and presses Enter.
217252 _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n")
218253 must(t, err)
219254 expect("unlocked")
220- must(t, cmd.Wait(), stderr.String())
255+ for range lines { // drain until the program closes stdout
256+ }
257+ must(t, wait(), stderr.String())
258+ // The typed text must not leak to the program's output.
259+ if strings.Contains(all.String(), "abc") || strings.Contains(stderr.String(), "abc") {
260+ t.Fatal("the typed text appears in the program output")
261+ }
221262 }
@@ -10,7 +10,9 @@ import (
10 "os"10 "os"
11 "os/exec"11 "os/exec"
12 "path/filepath"12 "path/filepath"
13+ "slices"
13 "strings"14 "strings"
15+ "sync"
14 "syscall"16 "syscall"
15 "testing"17 "testing"
16 "time"18 "time"
@@ -20,8 +22,9 @@ import (
20 // headless in an isolated runtime directory, and are skipped unless22 // headless in an isolated runtime directory, and are skipped unless
21 // NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to23 // NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to
22 // that nested compositor: the child environment is stripped of the caller's24 // that nested compositor: the child environment is stripped of the caller's
23-// session variables, and the socket is checked to live in the temporary25+// session variables, and the socket is checked to be absolute (an absolute
24-// directory. Never point these tests at a real session.26+// WAYLAND_DISPLAY is how the example finds it). Never point these tests at a
27+// real session.
25 28
26 var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",29 var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
27 "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",30 "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
@@ -48,6 +51,25 @@ func must(t *testing.T, err error, what ...any) {
48 }51 }
49 }52 }
50 53
54+// syncBuffer is a bytes buffer safe for the copying goroutine of exec and the
55+// test goroutine.
56+type syncBuffer struct {
57+ mu sync.Mutex
58+ b strings.Builder
59+}
60+
61+func (s *syncBuffer) Write(p []byte) (int, error) {
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ return s.b.Write(p)
65+}
66+
67+func (s *syncBuffer) String() string {
68+ s.mu.Lock()
69+ defer s.mu.Unlock()
70+ return s.b.String()
71+}
72+
51 type compositor struct {73 type compositor struct {
52 socket, shots string74 socket, shots string
53 env []string75 env []string
@@ -94,8 +116,8 @@ func startHeadless(t *testing.T) *compositor {
94 paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))116 paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))
95 for _, p := range paths {117 for _, p := range paths {
96 if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {118 if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
97- if !strings.HasPrefix(p, root) {119+ if !filepath.IsAbs(p) {
98- t.Fatalf("socket %s is outside the test directory", p)120+ t.Fatalf("socket %q is not absolute", p)
99 }121 }
100 return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin}122 return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin}
101 }123 }
@@ -125,7 +147,8 @@ func build(t *testing.T, pkg string) string {
125 // command prepares an example to run against c, and only against c.147 // command prepares an example to run against c, and only against c.
126 func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd {148 func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd {
127 cmd := exec.CommandContext(ctx, exe, args...)149 cmd := exec.CommandContext(ctx, exe, args...)
128- cmd.Env = append(c.env, "WAYLAND_DISPLAY="+c.socket)150+ // slices.Concat copies: c.env is never aliased or modified.
151+ cmd.Env = slices.Concat(c.env, []string{"WAYLAND_DISPLAY=" + c.socket})
129 return cmd152 return cmd
130 }153 }
131 154
@@ -153,8 +176,8 @@ func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool {
153 }176 }
154 177
155 func TestHeadlessLayer(t *testing.T) {178 func TestHeadlessLayer(t *testing.T) {
156- c := startHeadless(t)
157 exe := build(t, "./layer")179 exe := build(t, "./layer")
180+ c := startHeadless(t)
158 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)181 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
159 defer cancel()182 defer cancel()
160 cmd := c.command(ctx, exe, "-frames", "20")183 cmd := c.command(ctx, exe, "-frames", "20")
@@ -182,20 +205,31 @@ func TestHeadlessLayer(t *testing.T) {
182 }205 }
183 206
184 func TestHeadlessLock(t *testing.T) {207 func TestHeadlessLock(t *testing.T) {
185- c := startHeadless(t)
186 exe := build(t, "./lock")208 exe := build(t, "./lock")
209+ c := startHeadless(t)
187 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)210 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
188 defer cancel()211 defer cancel()
189 cmd := c.command(ctx, exe)212 cmd := c.command(ctx, exe)
190 stdout, err := cmd.StdoutPipe()213 stdout, err := cmd.StdoutPipe()
191 must(t, err)214 must(t, err)
192- var stderr strings.Builder215+ var stderr syncBuffer
193 cmd.Stderr = &stderr216 cmd.Stderr = &stderr
194 must(t, cmd.Start())217 must(t, cmd.Start())
218+ // Wait runs once: here, after the program is done, or from the cleanup if
219+ // the test fails first (the context kill ends the process).
220+ var waitOnce sync.Once
221+ var waitErr error
222+ wait := func() error {
223+ waitOnce.Do(func() { waitErr = cmd.Wait() })
224+ return waitErr
225+ }
226+ t.Cleanup(func() { cancel(); _ = wait() })
227+ var all syncBuffer // everything the program printed on stdout
195 lines := make(chan string, 8)228 lines := make(chan string, 8)
196 go func() {229 go func() {
197 sc := bufio.NewScanner(stdout)230 sc := bufio.NewScanner(stdout)
198 for sc.Scan() {231 for sc.Scan() {
232+ _, _ = all.Write(append(sc.Bytes(), '\n'))
199 lines <- sc.Text()233 lines <- sc.Text()
200 }234 }
201 close(lines)235 close(lines)
@@ -212,10 +246,17 @@ func TestHeadlessLock(t *testing.T) {
212 }246 }
213 }247 }
214 expect("locked")248 expect("locked")
249+ expect("focused") // the keyboard reached a lock surface: typing is safe
215 // Only the nested compositor receives this input; it types into the250 // Only the nested compositor receives this input; it types into the
216 // password field (shown masked) and presses Enter.251 // password field (shown masked) and presses Enter.
217 _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n")252 _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n")
218 must(t, err)253 must(t, err)
219 expect("unlocked")254 expect("unlocked")
220- must(t, cmd.Wait(), stderr.String())255+ for range lines { // drain until the program closes stdout
256+ }
257+ must(t, wait(), stderr.String())
258+ // The typed text must not leak to the program's output.
259+ if strings.Contains(all.String(), "abc") || strings.Contains(stderr.String(), "abc") {
260+ t.Fatal("the typed text appears in the program output")
261+ }
221 }262 }

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