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 + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + 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 + autowrapEnable + altScreenExit
183 const enterSequence = altScreenEnter + autowrapDisable + cursorHide +
184 mouseEnable + bracketedPasteEnable + colorSchemeEnable
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 + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + 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.Size), 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.Size(); err == nil {
490 t.Fatalf("expected Size() to error on a non-tty fd")
491 }
492}