1//go:build linux
2
3package ui
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "regexp"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/bnema/nefergui/internal/harness"
18 "github.com/bnema/nefergui/internal/keyboard"
19 "github.com/bnema/nefergui/internal/platform/wayland"
20)
21
22func TestSecretBufferEditing(t *testing.T) {
23 b := NewSecretBuffer(8)
24 b.append([]byte("a"))
25 b.append([]byte("é")) // two bytes, one code point
26 b.append([]byte("z"))
27 if b.Len() != 3 || string(b.Bytes()) != "aéz" {
28 t.Fatalf("len=%d", b.Len())
29 }
30 b.append([]byte{0xff}) // invalid UTF-8 is dropped
31 b.append(bytes.Repeat([]byte("x"), 9))
32 if b.Len() != 3 {
33 t.Fatal("invalid or oversized chunk accepted")
34 }
35 b.backspace()
36 b.backspace() // removes the two-byte code point whole
37 if b.Len() != 1 || !bytes.Equal(b.Bytes(), []byte("a")) {
38 t.Fatalf("backspace: len=%d", b.Len())
39 }
40 backing := b.buf
41 b.Wipe()
42 if b.Len() != 0 || len(b.Bytes()) != 0 || !bytes.Equal(backing, make([]byte, 8)) {
43 t.Fatal("buffer not wiped")
44 }
45 if got := (LockState{Mask: 3}).Bullets(); got != "•••" {
46 t.Fatal(got)
47 }
48}
49
50func TestConsumeSubmitIsAsyncAndNeverUnlocks(t *testing.T) {
51 var got [][]byte
52 secret := NewSecretBuffer(8)
53 l := &locker{cfg: LockConfig{Secret: secret, OnSubmit: func(b []byte) { got = append(got, bytes.Clone(b)) }}}
54 enter := keyboard.SecretKey{Kind: keyboard.SecretReturn, Pressed: true}
55 secret.append([]byte("pw"))
56 l.consume(enter, nil) // before locked: only clears
57 if len(got) != 0 || secret.Len() != 0 || l.unlock {
58 t.Fatalf("submit before locked: got=%d len=%d unlock=%v", len(got), secret.Len(), l.unlock)
59 }
60 l.locked = true
61 secret.append([]byte("pw"))
62 l.consume(enter, nil)
63 if len(got) != 1 || string(got[0]) != "pw" || secret.Len() != 0 || l.unlock {
64 t.Fatalf("submit after locked: got=%d len=%d unlock=%v", len(got), secret.Len(), l.unlock)
65 }
66 l.consume(enter, nil) // empty buffer submits nothing
67 if len(got) != 1 {
68 t.Fatal("empty submit reached the callback")
69 }
70}
71
72func TestShouldUnlockGate(t *testing.T) {
73 l := &locker{conn: &wayland.Connection{}}
74 if l.shouldUnlock() {
75 t.Fatal("unlock without request")
76 }
77 l.unlock = true // a request held before locked
78 if l.shouldUnlock() {
79 t.Fatal("unlock before locked")
80 }
81 if err := l.handle(wayland.Event{Kind: wayland.LockAcquired}); err != nil {
82 t.Fatal(err)
83 }
84 if !l.shouldUnlock() {
85 t.Fatal("held request not honoured once locked")
86 }
87 l.unlock = false
88 if l.shouldUnlock() {
89 t.Fatal("unlock without request after locked")
90 }
91}
92
93func TestConsumeWipesWhenOnSubmitPanics(t *testing.T) {
94 secret := NewSecretBuffer(8)
95 l := &locker{locked: true, cfg: LockConfig{Secret: secret, OnSubmit: func([]byte) { panic("boom") }}}
96 secret.append([]byte("pw"))
97 func() {
98 defer func() { _ = recover() }()
99 l.consume(keyboard.SecretKey{Kind: keyboard.SecretReturn, Pressed: true}, nil)
100 }()
101 if secret.Len() != 0 || !bytes.Equal(secret.buf, make([]byte, 8)) {
102 t.Fatal("secret not wiped after OnSubmit panic")
103 }
104}
105
106func TestForwardWakeStopsWithContext(t *testing.T) {
107 ctx, cancel := context.WithCancel(context.Background())
108 done := make(chan struct{})
109 go func() { newRuntime().forwardWake(ctx, make(chan struct{})); close(done) }()
110 cancel()
111 select {
112 case <-done:
113 case <-time.After(5 * time.Second):
114 t.Fatal("forwardWake outlived its context")
115 }
116}
117
118func TestHarnessRunLock(t *testing.T) {
119 if os.Getenv("NEFERGUI_HARNESS") != "1" {
120 t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the session-lock harness")
121 }
122 root := t.TempDir()
123 script := filepath.Join(root, "input.txt")
124 // Enough time for lock + first frame; then a wrong secret, clear, and the right one.
125 if err := os.WriteFile(script, []byte("sleep 4s\ntype wrong\nkey Escape\nsleep 200ms\ntype open\nkey Return\nsleep 1s\n"), 0600); err != nil {
126 t.Fatal(err)
127 }
128 result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: filepath.Join(root, "artifacts"), AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Input: script, Timeout: 40 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessRunLockClient$", "-test.v"}})
129 out, _ := os.ReadFile(result.Artifacts["client_stdout"])
130 if code != harness.Pass {
131 errOut, _ := os.ReadFile(result.Artifacts["client_stderr"])
132 t.Fatalf("code=%d %s\nstdout:\n%s\nstderr:\n%s", code, result.Error, out, errOut)
133 }
134 t.Logf("client output:\n%s", out)
135 // Frames coalesce, so only the shape is asserted: the mask rises, Escape
136 // clears it, and the accepted secret is wiped.
137 for _, want := range []string{"LOCK locked", "LOCK frame", "LOCK submit ok", "LOCK unlocked [0 ", "LOCK wake stopped"} {
138 if !strings.Contains(string(out), want) {
139 t.Fatalf("missing %q in:\n%s", want, out)
140 }
141 }
142 // A mask above zero was drawn before the final wipe to zero.
143 if !regexp.MustCompile(`LOCK unlocked \[0( [0-9]+)* [1-9][0-9]* 0\]`).Match(out) {
144 t.Fatalf("no mask above 0 before the final 0 in:\n%s", out)
145 }
146}
147
148func TestHarnessRunLockClient(t *testing.T) {
149 if os.Getenv("NEFERGUI_DEBUG_DIR") == "" {
150 t.Skip("harness child only")
151 }
152 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
153 defer cancel()
154 secret := NewSecretBuffer(64)
155 unlock, status := make(chan struct{}, 1), make(chan LockStatus, 4)
156 frames := 0
157 var maskSeen []int
158 wake := make(chan struct{})
159 err := RunLock(ctx, LockConfig{
160 Secret: secret,
161 Wake: wake,
162 View: func(f *Frame, s LockState) {
163 if frames == 0 {
164 fmt.Println("LOCK frame")
165 }
166 frames++
167 if len(maskSeen) == 0 || maskSeen[len(maskSeen)-1] != s.Mask {
168 maskSeen = append(maskSeen, s.Mask)
169 }
170 col := f.Root().Column()
171 col.Text(s.Bullets(), Key("mask"))
172 },
173 OnLocked: func() { fmt.Println("LOCK locked") },
174 Unlock: unlock,
175 Status: status,
176 OnSubmit: func(b []byte) {
177 // Test-only comparison; real callers hand the bytes to a verifier.
178 if string(b) == "open" {
179 fmt.Println("LOCK submit ok")
180 status <- LockBusy
181 go func() { time.Sleep(300 * time.Millisecond); unlock <- struct{}{} }()
182 }
183 },
184 })
185 if err != nil {
186 t.Fatal(err)
187 }
188 if secret.Len() != 0 {
189 t.Fatal("secret not wiped after submit")
190 }
191 select {
192 case wake <- struct{}{}:
193 t.Fatal("wake forwarder outlived RunLock")
194 case <-time.After(200 * time.Millisecond):
195 fmt.Println("LOCK wake stopped")
196 }
197 fmt.Println("LOCK unlocked", maskSeen)
198}
199
200func TestOutputFailurePolicy(t *testing.T) {
201 boom := errors.New("boom")
202 if err := outputFailure(true, "lock surface", "DP-1", boom); !errors.Is(err, boom) {
203 t.Fatalf("initial output failure must abort: %v", err)
204 }
205 if err := outputFailure(false, "lock surface", "DP-2", boom); err != nil {
206 t.Fatalf("hotplug failure must be skipped: %v", err)
207 }
208 var reported []string
209 l := &locker{cfg: LockConfig{OnOutputError: func(o string, err error) {
210 if errors.Is(err, boom) {
211 reported = append(reported, o)
212 }
213 }}}
214 if err := l.outputFailure(false, "lock surface", "DP-4", boom); err != nil || len(reported) != 1 || reported[0] != "DP-4" {
215 t.Fatalf("hotplug failure must be reported and skipped: %v %v", err, reported)
216 }
217 if err := l.outputFailure(true, "lock surface", "DP-5", boom); err == nil || len(reported) != 1 {
218 t.Fatalf("initial failure must abort without a skip report: %v %v", err, reported)
219 }
220 for _, initial := range []bool{true, false} {
221 err := outputFailure(initial, "lock surface", "DP-3", fmt.Errorf("x: %w", wayland.ErrLockFinished))
222 if !errors.Is(err, ErrLockFinished) || !errors.Is(err, wayland.ErrLockFinished) {
223 t.Fatalf("finished (initial=%v) must map to ErrLockFinished: %v", initial, err)
224 }
225 }
226}
227
228// Events kept during setup reach handle() exactly as reader events do.
229func TestHeldLockEventsDriveLocker(t *testing.T) {
230 onLocked := 0
231 l := &locker{conn: &wayland.Connection{}, cfg: LockConfig{OnLocked: func() { onLocked++ }}}
232 if err := l.handle(wayland.Event{Kind: wayland.LockAcquired}); err != nil || !l.locked || onLocked != 1 {
233 t.Fatalf("locked lost: err=%v locked=%v calls=%d", err, l.locked, onLocked)
234 }
235 l.unlock = true
236 if !l.shouldUnlock() {
237 t.Fatal("unlock not possible after a held locked")
238 }
239 if err := l.handle(wayland.Event{Kind: wayland.LockFinished}); !errors.Is(err, ErrLockFinished) {
240 t.Fatalf("finished: %v", err)
241 }
242}
243
244// A second locker is refused by the compositor with `finished`, which must
245// surface as ErrLockFinished however early it arrives during setup.
246func TestHarnessRunLockRefused(t *testing.T) {
247 if os.Getenv("NEFERGUI_HARNESS") != "1" {
248 t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the session-lock harness")
249 }
250 result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: filepath.Join(t.TempDir(), "artifacts"), AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Timeout: 40 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessRunLockRefusedClient$", "-test.v"}})
251 out, _ := os.ReadFile(result.Artifacts["client_stdout"])
252 // A protected compositor withholds debug PNGs by design, so the harness may
253 // report a missing frame; this test only needs the client's refusal result.
254 noFrame := code == harness.ProcessFailure && strings.HasPrefix(result.Error, "no frame")
255 if (code != harness.Pass && !noFrame) || !strings.Contains(string(out), "LOCK refused ok") {
256 errOut, _ := os.ReadFile(result.Artifacts["client_stderr"])
257 t.Fatalf("code=%d %s\nstdout:\n%s\nstderr:\n%s", code, result.Error, out, errOut)
258 }
259}
260
261func TestHarnessRunLockRefusedClient(t *testing.T) {
262 if os.Getenv("NEFERGUI_DEBUG_DIR") == "" {
263 t.Skip("harness child only")
264 }
265 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
266 defer cancel()
267 first, err := wayland.ConnectConnection(ctx, "")
268 if err != nil {
269 t.Fatal(err)
270 }
271 defer first.Close()
272 if err = first.AcquireLock(); err != nil {
273 t.Fatal(err)
274 }
275 // The first owner stays alive and unanswered; the compositor refuses the next.
276 err = RunLock(ctx, LockConfig{Secret: NewSecretBuffer(8), View: func(*Frame, LockState) {}})
277 if !errors.Is(err, ErrLockFinished) {
278 t.Fatalf("refused lock: %v", err)
279 }
280 fmt.Println("LOCK refused ok")
281}