v0.7.0 4c16dd8

terminal_test.go RawHistoryFolder

1package term
2
3import (
4	"bytes"
5	"errors"
6	"io"
7	"os"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/bnema/vev/internal/domain"
13)
14
15// pipePair returns an os.Pipe and a goroutine that continuously copies
16// everything written to the write end into captured, closing done once
17// the write end is closed (EOF).
18func pipeCapture(t *testing.T) (r, w *os.File, captured *bytes.Buffer, done chan struct{}) {
19	t.Helper()
20	r, w, err := os.Pipe()
21	if err != nil {
22		t.Fatalf("os.Pipe: %v", err)
23	}
24	captured = &bytes.Buffer{}
25	done = make(chan struct{})
26	go func() {
27		_, _ = io.Copy(captured, r)
28		close(done)
29	}()
30	return r, w, captured, done
31}
32
33func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
34	inR, inW, err := os.Pipe()
35	if err != nil {
36		t.Fatalf("os.Pipe(in): %v", err)
37	}
38	defer func() { _ = inW.Close() }()
39	defer func() { _ = inR.Close() }()
40
41	outR, outW, captured, done := pipeCapture(t)
42	defer func() { _ = outR.Close() }()
43
44	tm := NewWithFiles(inR, outW)
45
46	restore, err := tm.EnterRaw()
47	if err != nil {
48		t.Fatalf("EnterRaw: %v", err)
49	}
50	if !tm.rawSkipped {
51		t.Fatalf("expected rawSkipped=true for a non-tty fd")
52	}
53
54	if err := restore(); err != nil {
55		t.Fatalf("restore: %v", err)
56	}
57	// Idempotent: calling again must not error or duplicate output.
58	if err := restore(); err != nil {
59		t.Fatalf("second restore call: %v", err)
60	}
61
62	_ = outW.Close()
63	<-done
64
65	want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
66	if got := captured.String(); got != want {
67		t.Fatalf("captured escapes = %q, want %q", got, want)
68	}
69	if got := captured.String(); bytes.Contains([]byte(got), []byte("\x1b]10;?")) || bytes.Contains([]byte(got), []byte("\x1b]4;")) {
70		t.Fatalf("EnterRaw emitted a color query: %q", got)
71	}
72}
73
74// A frame the daemon composed for a wider terminal must never bleed onto the
75// next row: with DECAWM left on, an over-long styled run (the full-width tab
76// bar) wraps and paints a second row in the accent colour. vev positions every
77// row explicitly and never relies on the terminal wrapping for it, so autowrap
78// stays off for as long as vev owns the alt screen.
79func TestTerminal_EnterRaw_DisablesAutowrapInsideAltScreen(t *testing.T) {
80	const (
81		autowrapOff = "\x1b[?7l"
82		autowrapOn  = "\x1b[?7h"
83		altEnter    = "\x1b[?1049h"
84		altExit     = "\x1b[?1049l"
85	)
86
87	inR, inW, err := os.Pipe()
88	if err != nil {
89		t.Fatalf("os.Pipe(in): %v", err)
90	}
91	defer func() { _ = inW.Close() }()
92	defer func() { _ = inR.Close() }()
93
94	outR, outW, captured, done := pipeCapture(t)
95	defer func() { _ = outR.Close() }()
96
97	tm := NewWithFiles(inR, outW)
98
99	restore, err := tm.EnterRaw()
100	if err != nil {
101		t.Fatalf("EnterRaw: %v", err)
102	}
103	if err := restore(); err != nil {
104		t.Fatalf("restore: %v", err)
105	}
106
107	_ = outW.Close()
108	<-done
109	got := captured.String()
110
111	for _, tc := range []struct {
112		name string
113		seq  string
114	}{
115		{"autowrap disabled exactly once", autowrapOff},
116		{"autowrap restored exactly once", autowrapOn},
117	} {
118		t.Run(tc.name, func(t *testing.T) {
119			if n := strings.Count(got, tc.seq); n != 1 {
120				t.Fatalf("emitted %q %d times, want exactly 1; full output %q", tc.seq, n, got)
121			}
122		})
123	}
124
125	// Ordering: disabling autowrap must land inside the alt screen, and it
126	// must be restored before vev hands the screen back to the shell.
127	for _, tc := range []struct {
128		name   string
129		before string
130		after  string
131	}{
132		{"alt screen entered before autowrap is disabled", altEnter, autowrapOff},
133		{"autowrap disabled before it is restored", autowrapOff, autowrapOn},
134		{"autowrap restored before leaving the alt screen", autowrapOn, altExit},
135	} {
136		t.Run(tc.name, func(t *testing.T) {
137			i, j := strings.Index(got, tc.before), strings.Index(got, tc.after)
138			if i < 0 || j < 0 {
139				t.Fatalf("missing sequence: %q at %d, %q at %d; full output %q", tc.before, i, tc.after, j, got)
140			}
141			if i >= j {
142				t.Fatalf("%q (index %d) must precede %q (index %d); full output %q", tc.before, i, tc.after, j, got)
143			}
144		})
145	}
146}
147
148// prefixThenFailWriter forwards at most prefix bytes to sink and then reports
149// a failure, standing in for a tty that accepts part of a mode sequence and
150// then stops. A prefix of 0 fails without emitting anything.
151type prefixThenFailWriter struct {
152	sink   io.Writer
153	prefix int
154}
155
156func (w *prefixThenFailWriter) Write(p []byte) (int, error) {
157	if len(p) > w.prefix {
158		p = p[:w.prefix]
159	}
160	n := 0
161	if len(p) > 0 {
162		var err error
163		n, err = w.sink.Write(p)
164		if err != nil {
165			return n, err
166		}
167	}
168	w.prefix -= n
169	return n, errors.New("sink failed after prefix")
170}
171
172// When the batched writer fails, part of the mode sequence may already have
173// reached the terminal, so the visual modes must be reset straight to the fd
174// rather than through the writer that just failed. Poisoning tm.bw while
175// leaving tm.out intact isolates that fallback: the buffered path fails, the
176// real descriptor still records what the fallback attempted.
177func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) {
178	// Every mode EnterRaw can turn on has to be turned back off, not just the
179	// alt screen: a partial emission that got as far as cursorHide/mouseEnable
180	// would otherwise leave the cursor hidden and mouse reporting on.
181	const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable +
182		bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
183	const enterSequence = altScreenEnter + autowrapDisable + cursorHide +
184		mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable
185
186	for _, tc := range []struct {
187		name        string
188		prefix      int  // bytes the sink accepts before failing
189		poisonAfter bool // poison the writer after EnterRaw, i.e. fail during restore
190		want        string
191	}{
192		{
193			name: "enter fails before emitting anything",
194			want: wantCleanup,
195		},
196		{
197			name:   "enter fails after emitting part of the sequence",
198			prefix: len(altScreenEnter + autowrapDisable + cursorHide),
199			want:   altScreenEnter + autowrapDisable + cursorHide + wantCleanup,
200		},
201		{
202			name:        "restore fails after a successful enter",
203			poisonAfter: true,
204			want:        enterSequence + wantCleanup,
205		},
206	} {
207		t.Run(tc.name, func(t *testing.T) {
208			inR, inW, err := os.Pipe()
209			if err != nil {
210				t.Fatalf("os.Pipe(in): %v", err)
211			}
212			defer func() { _ = inW.Close() }()
213			defer func() { _ = inR.Close() }()
214
215			outR, outW, captured, done := pipeCapture(t)
216			defer func() { _ = outR.Close() }()
217
218			tm := NewWithFiles(inR, outW)
219			poison := func() {
220				tm.bw = newBatchWriter(&prefixThenFailWriter{sink: outW, prefix: tc.prefix}, bufSize)
221			}
222
223			if !tc.poisonAfter {
224				poison()
225				if _, err := tm.EnterRaw(); err == nil {
226					t.Fatalf("EnterRaw returned nil error on a failing sink")
227				}
228			} else {
229				restore, err := tm.EnterRaw()
230				if err != nil {
231					t.Fatalf("EnterRaw: %v", err)
232				}
233				poison()
234				if err := restore(); err == nil {
235					t.Fatalf("restore returned nil error on a failing sink")
236				}
237			}
238
239			_ = outW.Close()
240			<-done
241
242			if got := captured.String(); got != tc.want {
243				t.Fatalf("bytes written to the fd = %q, want %q", got, tc.want)
244			}
245		})
246	}
247}
248
249func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
250	inR, inW, err := os.Pipe()
251	if err != nil {
252		t.Fatalf("os.Pipe(in): %v", err)
253	}
254	defer func() { _ = inW.Close() }()
255	defer func() { _ = inR.Close() }()
256
257	outR, outW, captured, done := pipeCapture(t)
258	defer func() { _ = outR.Close() }()
259
260	tm := NewWithFiles(inR, outW)
261
262	restore1, err := tm.EnterRaw()
263	if err != nil {
264		t.Fatalf("EnterRaw #1: %v", err)
265	}
266	restore2, err := tm.EnterRaw()
267	if err != nil {
268		t.Fatalf("EnterRaw #2: %v", err)
269	}
270
271	if err := restore1(); err != nil {
272		t.Fatalf("restore1: %v", err)
273	}
274	if err := restore2(); err != nil {
275		t.Fatalf("restore2: %v", err)
276	}
277
278	_ = outW.Close()
279	<-done
280
281	// Alt-screen/cursor escapes must appear exactly once for enter and exits
282	// exactly once, regardless of how many times EnterRaw/restore were called.
283	want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
284	if got := captured.String(); got != want {
285		t.Fatalf("captured escapes = %q, want %q", got, want)
286	}
287}
288
289func TestTerminal_InOut(t *testing.T) {
290	inR, inW, err := os.Pipe()
291	if err != nil {
292		t.Fatalf("os.Pipe(in): %v", err)
293	}
294	defer func() { _ = inW.Close() }()
295	defer func() { _ = inR.Close() }()
296
297	outR, outW, captured, done := pipeCapture(t)
298	defer func() { _ = outR.Close() }()
299
300	tm := NewWithFiles(inR, outW)
301
302	if tm.In() == nil {
303		t.Fatalf("In() = nil")
304	}
305
306	if _, err := tm.Out().Write([]byte("payload")); err != nil {
307		t.Fatalf("Out().Write: %v", err)
308	}
309	if err := tm.Flush(); err != nil {
310		t.Fatalf("Flush: %v", err)
311	}
312
313	_ = outW.Close()
314	<-done
315
316	if got := captured.String(); got != "payload" {
317		t.Fatalf("captured = %q, want %q", got, "payload")
318	}
319}
320
321func TestTerminal_ResizeEvents_ReturnsSameChannelAndClosesOnRestore(t *testing.T) {
322	inR, inW, err := os.Pipe()
323	if err != nil {
324		t.Fatalf("os.Pipe(in): %v", err)
325	}
326	defer func() { _ = inW.Close() }()
327	defer func() { _ = inR.Close() }()
328
329	outR, outW, _, done := pipeCapture(t)
330	defer func() { _ = outR.Close() }()
331
332	tm := NewWithFiles(inR, outW)
333
334	restore, err := tm.EnterRaw()
335	if err != nil {
336		t.Fatalf("EnterRaw: %v", err)
337	}
338
339	ch1 := tm.ResizeEvents()
340	ch2 := tm.ResizeEvents()
341	if ch1 != ch2 {
342		t.Fatalf("ResizeEvents() returned different channels across calls")
343	}
344
345	if err := restore(); err != nil {
346		t.Fatalf("restore: %v", err)
347	}
348
349	select {
350	case _, ok := <-ch1:
351		if ok {
352			t.Fatalf("expected resize channel to be closed after restore")
353		}
354	case <-time.After(2 * time.Second):
355		t.Fatalf("resize channel was not closed after restore")
356	}
357
358	_ = outW.Close()
359	<-done
360}
361
362func TestTerminal_ResizeEvents_AfterRestore_NoWatcherAndClosedChannel(t *testing.T) {
363	inR, inW, err := os.Pipe()
364	if err != nil {
365		t.Fatalf("os.Pipe(in): %v", err)
366	}
367	defer func() { _ = inW.Close() }()
368	defer func() { _ = inR.Close() }()
369
370	outR, outW, _, done := pipeCapture(t)
371	defer func() { _ = outR.Close() }()
372
373	tm := NewWithFiles(inR, outW)
374
375	restore, err := tm.EnterRaw()
376	if err != nil {
377		t.Fatalf("EnterRaw: %v", err)
378	}
379	// restore BEFORE the first ResizeEvents call (signal-driven shutdown
380	// racing ahead of the resize pump's startup).
381	if err := restore(); err != nil {
382		t.Fatalf("restore: %v", err)
383	}
384
385	ch := tm.ResizeEvents()
386
387	// The channel must already be closed so a consumer selecting on it
388	// doesn't hang.
389	select {
390	case _, ok := <-ch:
391		if ok {
392			t.Fatalf("expected closed resize channel, got a value")
393		}
394	case <-time.After(2 * time.Second):
395		t.Fatalf("resize channel not closed after restore-before-ResizeEvents")
396	}
397
398	// No watcher may have started: no signal handler installed, no quit
399	// channel allocated, no goroutine registered.
400	tm.mu.Lock()
401	sigCh, quit := tm.sigCh, tm.resizeQuit
402	tm.mu.Unlock()
403	if sigCh != nil {
404		t.Fatalf("expected no signal channel (no signal.Notify) after restore-first ordering")
405	}
406	if quit != nil {
407		t.Fatalf("expected no quit channel (no watcher goroutine) after restore-first ordering")
408	}
409	// Returns immediately iff no watcher goroutine was leaked.
410	tm.resizeWG.Wait()
411
412	_ = outW.Close()
413	<-done
414}
415
416func TestTerminal_ResizeEvents_ConcurrentWithRestore(t *testing.T) {
417	// Race ResizeEvents against a restore triggered from another
418	// goroutine (the signal-driven shutdown shape). Under -race this
419	// locks in that watcher start/stop are ordered by the mutex; in all
420	// interleavings the returned channel must eventually be closed and
421	// no watcher goroutine may leak.
422	for i := range 50 {
423		inR, inW, err := os.Pipe()
424		if err != nil {
425			t.Fatalf("os.Pipe(in): %v", err)
426		}
427		outR, outW, _, done := pipeCapture(t)
428
429		tm := NewWithFiles(inR, outW)
430		restore, err := tm.EnterRaw()
431		if err != nil {
432			t.Fatalf("EnterRaw: %v", err)
433		}
434
435		start := make(chan struct{})
436		chch := make(chan (<-chan domain.Geometry), 1)
437		go func() {
438			<-start
439			chch <- tm.ResizeEvents()
440		}()
441		go func() {
442			<-start
443			_ = restore()
444		}()
445		close(start)
446
447		ch := <-chch
448		// restore may still be in flight; it is idempotent and returns
449		// only after the watcher (if any) has fully stopped.
450		if err := restore(); err != nil {
451			t.Fatalf("iteration %d: restore: %v", i, err)
452		}
453
454		select {
455		case _, ok := <-ch:
456			if ok {
457				// A real size emission is impossible here (pipe, not a
458				// tty, and no SIGWINCH sent) — anything but a close is a
459				// bug.
460				t.Fatalf("iteration %d: expected closed channel, got a value", i)
461			}
462		case <-time.After(2 * time.Second):
463			t.Fatalf("iteration %d: resize channel never closed", i)
464		}
465		tm.resizeWG.Wait()
466
467		_ = outW.Close()
468		<-done
469		_ = inR.Close()
470		_ = inW.Close()
471		_ = outR.Close()
472	}
473}
474
475func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) {
476	inR, inW, err := os.Pipe()
477	if err != nil {
478		t.Fatalf("os.Pipe(in): %v", err)
479	}
480	defer func() { _ = inW.Close() }()
481	defer func() { _ = inR.Close() }()
482
483	outR, outW, _, _ := pipeCapture(t)
484	defer func() { _ = outR.Close() }()
485	defer func() { _ = outW.Close() }()
486
487	tm := NewWithFiles(inR, outW)
488
489	if _, err := tm.Geometry(); err == nil {
490		t.Fatalf("expected Geometry() to error on a non-tty fd")
491	}
492}
493
494func TestTerminal_KittyKeyboardPushedInsideAltScreenAndPoppedBeforeExit(t *testing.T) {
495	tests := []struct {
496		name   string
497		enable bool
498		before bool // enable before EnterRaw: must be a no-op
499		want   string
500	}{
501		{name: "disabled", want: visualEnter + visualRestore},
502		{name: "enabled", enable: true, want: visualEnter + "\x1b[>5u" + kittyKeyboardPop + visualRestore},
503		{name: "enable before enter is ignored", enable: true, before: true, want: visualEnter + visualRestore},
504	}
505	for _, tt := range tests {
506		t.Run(tt.name, func(t *testing.T) {
507			inR, inW, err := os.Pipe()
508			if err != nil {
509				t.Fatalf("os.Pipe(in): %v", err)
510			}
511			defer func() { _ = inW.Close() }()
512			defer func() { _ = inR.Close() }()
513			outR, outW, captured, done := pipeCapture(t)
514			defer func() { _ = outR.Close() }()
515
516			tm := NewWithFiles(inR, outW)
517			if tt.before {
518				if err := tm.EnableKittyKeyboard(5); err != nil {
519					t.Fatalf("EnableKittyKeyboard: %v", err)
520				}
521			}
522			restore, err := tm.EnterRaw()
523			if err != nil {
524				t.Fatalf("EnterRaw: %v", err)
525			}
526			if tt.enable && !tt.before {
527				if err := tm.EnableKittyKeyboard(5); err != nil {
528					t.Fatalf("EnableKittyKeyboard: %v", err)
529				}
530				// A second enable must not push twice.
531				if err := tm.EnableKittyKeyboard(5); err != nil {
532					t.Fatalf("second EnableKittyKeyboard: %v", err)
533				}
534			}
535			if err := restore(); err != nil {
536				t.Fatalf("restore: %v", err)
537			}
538			_ = outW.Close()
539			<-done
540			if got := captured.String(); got != tt.want {
541				t.Fatalf("captured escapes = %q, want %q", got, tt.want)
542			}
543		})
544	}
545}

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 12

Tags 11

↑↓ moveenter openesc close