v0.3.0 6800c6a

lock_test.go RawHistoryFolder

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}

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 12

↑↓ moveenter openesc close