v0.2.0 80c61e3

clipboard_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"encoding/base64"
6	"errors"
7	"io"
8	"os"
9	"path/filepath"
10	"strings"
11	"sync"
12	"testing"
13	"time"
14
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17
18	"github.com/bnema/vev/internal/domain"
19	"github.com/bnema/vev/internal/ports"
20	portsmocks "github.com/bnema/vev/internal/ports/mocks"
21	scopy "github.com/bnema/vev/internal/usecase/copy"
22)
23
24func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) {
25	normalWrites := make(chan []byte, 1)
26	floatingWrites := make(chan []byte, 1)
27	normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
28	floatingPTY, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
29	defer releaseNormal()
30	defer releaseFloating()
31	d, sess, _, _ := newManualSessionWithPTYs(t, normal)
32	floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
33	floating.screen.Write([]byte("\x1b[?2004h"))
34	installTestFloating(testAttachmentTab(sess), floating, true)
35
36	d.injectClipboardPath(sess, "/tmp/image.png")
37
38	requirePTYWrite(t, floatingWrites, []byte("\x1b[200~/tmp/image.png\x1b[201~"))
39	requireNoPTYWrite(t, normalWrites)
40}
41
42func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
43	writes := make(chan []byte, 1)
44	p, releasePTY := newBlockingPTYWithWrites(t, writes)
45	defer releasePTY()
46	d, sess, _, _ := newManualSessionWithPTYs(t, p)
47	d.tempDir = t.TempDir()
48
49	data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
50	d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: data})
51
52	injected := <-writes
53	path := string(injected)
54	require.FileExists(t, path)
55	require.Equal(t, ".png", filepath.Ext(path))
56	require.True(t, filepath.IsAbs(path))
57
58	got, err := os.ReadFile(path)
59	require.NoError(t, err)
60	require.Equal(t, data, got)
61
62	info, err := os.Stat(path)
63	require.NoError(t, err)
64	require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
65}
66
67func TestHandleImagePushInjectsPathWithoutBracketedPasteWrapping(t *testing.T) {
68	writes := make(chan []byte, 1)
69	p, releasePTY := newBlockingPTYWithWrites(t, writes)
70	defer releasePTY()
71	d, sess, _, _ := newManualSessionWithPTYs(t, p)
72	d.tempDir = t.TempDir()
73	pane := sess.tabs[0].focusedPane()
74	require.False(t, pane.screen.BracketedPasteMode())
75
76	d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
77
78	injected := <-writes
79	require.False(t, hasBracketedPasteMarkers(injected), "path must not be wrapped when the pane is not in bracketed-paste mode")
80	require.FileExists(t, string(injected))
81}
82
83func TestHandleImagePushInjectsPathWithBracketedPasteWrapping(t *testing.T) {
84	writes := make(chan []byte, 1)
85	p, releasePTY := newBlockingPTYWithWrites(t, writes)
86	defer releasePTY()
87	d, sess, _, _ := newManualSessionWithPTYs(t, p)
88	d.tempDir = t.TempDir()
89	pane := sess.tabs[0].focusedPane()
90	pane.screen.Write([]byte("\x1b[?2004h"))
91	require.True(t, pane.screen.BracketedPasteMode())
92
93	d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
94
95	injected := <-writes
96	require.True(t, len(injected) > len(clipPasteOpenMarker)+len(clipPasteCloseMarker))
97	require.Equal(t, clipPasteOpenMarker, injected[:len(clipPasteOpenMarker)])
98	require.Equal(t, clipPasteCloseMarker, injected[len(injected)-len(clipPasteCloseMarker):])
99	path := string(injected[len(clipPasteOpenMarker) : len(injected)-len(clipPasteCloseMarker)])
100	require.FileExists(t, path)
101}
102
103func hasBracketedPasteMarkers(b []byte) bool {
104	return len(b) >= len(clipPasteOpenMarker) &&
105		string(b[:len(clipPasteOpenMarker)]) == string(clipPasteOpenMarker)
106}
107
108func TestHandleImagePushRejectsOversizedPayloadWithoutWritingOrInjecting(t *testing.T) {
109	writes := make(chan []byte, 1)
110	p, releasePTY := newBlockingPTYWithWrites(t, writes)
111	defer releasePTY()
112	d, sess, _, _ := newManualSessionWithPTYs(t, p)
113	dir := t.TempDir()
114	d.tempDir = dir
115
116	huge := make([]byte, maxImagePushSize+1)
117	d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: huge})
118
119	select {
120	case w := <-writes:
121		t.Fatalf("oversized push must not be injected into the pane, got %q", w)
122	default:
123	}
124	entries, err := os.ReadDir(dir)
125	require.NoError(t, err)
126	require.Empty(t, entries, "oversized push must not write a temp file")
127}
128
129func TestHandleImagePushIgnoresEmptyPayload(t *testing.T) {
130	writes := make(chan []byte, 1)
131	p, releasePTY := newBlockingPTYWithWrites(t, writes)
132	defer releasePTY()
133	d, sess, _, _ := newManualSessionWithPTYs(t, p)
134	dir := t.TempDir()
135	d.tempDir = dir
136
137	d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: nil})
138
139	select {
140	case w := <-writes:
141		t.Fatalf("empty push must not be injected, got %q", w)
142	default:
143	}
144	entries, err := os.ReadDir(dir)
145	require.NoError(t, err)
146	require.Empty(t, entries)
147}
148
149func TestClipboardExtMapsKnownMimeTypesAndFallsBackToBin(t *testing.T) {
150	cases := map[string]string{
151		"image/png":                "png",
152		"image/jpeg":               "jpg",
153		"image/webp":               "webp",
154		"image/gif":                "gif",
155		"image/bmp":                "bin",
156		"":                         "bin",
157		"application/octet-stream": "bin",
158	}
159	for mime, want := range cases {
160		require.Equal(t, want, clipboardExt(mime), "mime %q", mime)
161	}
162}
163
164func TestKillSessionRemovesClipboardTempFiles(t *testing.T) {
165	writes := make(chan []byte, 1)
166	p, releasePTY := newBlockingPTYWithWrites(t, writes)
167	defer releasePTY()
168	d, sess, _, _ := newManualSessionWithPTYs(t, p)
169	d.tempDir = t.TempDir()
170	// killSession requires the session to be registered in the daemon's
171	// registry (newManualSessionWithPTYs already does this via d.sessions).
172
173	d.handleImagePush(sess, ports.ImagePush{Mime: "image/png", Data: []byte("x")})
174	injected := <-writes
175	path := string(injected)
176	require.FileExists(t, path)
177
178	require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
179	require.NoFileExists(t, path)
180}
181
182// chunkReadPTY returns a MockPTY whose Read yields each chunk in order, then
183// reports io.EOF — driving ptyReader through exactly len(chunks) reads before
184// it unwinds via reapPane, matching the style of TestPTYQueryGetsResponseWrittenBackToPTY.
185func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
186	t.Helper()
187	p := portsmocks.NewMockPTY(t)
188	p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
189		if len(chunks) == 0 {
190			return 0, io.EOF
191		}
192		n := copy(buf, chunks[0])
193		if n == len(chunks[0]) {
194			chunks = chunks[1:]
195		} else {
196			chunks[0] = chunks[0][n:]
197		}
198		return n, nil
199	})
200	p.EXPECT().Write(mock.Anything).Return(0, nil).Maybe()
201	p.EXPECT().Close().Return(nil).Maybe()
202	return p
203}
204
205func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206	rc := d.attachCoordinator(sess, nil, ac, true)
207	token := sess.attachmentToken(ac, tr)
208	token.lease = rc.attachmentLease(ac)
209	ac.publishAttachmentCapability(token)
210}
211
212func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
213	t.Helper()
214	tb := testAttachmentTab(sess)
215	tb.mu.Lock()
216	p := tb.terminalTargetLocked()
217	tb.mu.Unlock()
218	if p == nil {
219		t.Fatal("clipboard owner has no terminal target")
220	}
221	return p.effectLease()
222}
223
224func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
225	d := newTestDaemon(t, nil, stubClock{})
226	p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
227
228	tr, sends := newCapturingTransport(t)
229	sctx, cancel := context.WithCancel(context.Background())
230	defer cancel()
231	win := newTestTabWithContext(p, sctx, cancel)
232	ac := &attachedClient{tr: tr, output: newOutputStateStream()}
233	ac.initOverlays()
234	sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
235	publishTiledPaneOwners(sess, win)
236	ac.setSession(sess)
237	d.sessions[sess.id] = sess
238	publishActiveClipboardCapability(d, sess, ac, tr)
239
240	d.sessWg.Add(1)
241	d.ptyReader(sess, win, win.focusedPane())
242
243	var clipboardOutput string
244	for range 3 {
245		f := awaitFrame(t, sends, ports.MsgOutput)
246		out, err := ports.UnmarshalOutput(f.Payload)
247		require.NoError(t, err)
248		clipboardOutput = string(out.Data)
249		if strings.Contains(clipboardOutput, "\x1b]52;c;aGVsbG8=\x07") {
250			break
251		}
252	}
253	require.Contains(t, clipboardOutput, "\x1b]52;c;aGVsbG8=\x07")
254}
255
256func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
257	d := newTestDaemon(t, nil, stubClock{})
258	big := strings.Repeat("a", scopy.OSC52MaxPayloadBytes+1)
259	b64 := base64.StdEncoding.EncodeToString([]byte(big))
260	p := chunkReadPTY(t, []byte("\x1b]52;c;"+b64+"\x07"))
261
262	tr, sends := newCapturingTransport(t)
263	sctx, cancel := context.WithCancel(context.Background())
264	defer cancel()
265	win := newTestTabWithContext(p, sctx, cancel)
266	ac := &attachedClient{tr: tr, output: newOutputStateStream()}
267	ac.initOverlays()
268	sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
269	publishTiledPaneOwners(sess, win)
270	ac.setSession(sess)
271	d.sessions[sess.id] = sess
272
273	d.sessWg.Add(1)
274	d.ptyReader(sess, win, win.focusedPane())
275
276	select {
277	case f := <-sends:
278		require.NotEqual(t, ports.MsgOutput, f.Type)
279	default:
280	}
281}
282
283func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
284	d := newTestDaemon(t, nil, stubClock{})
285	p := chunkReadPTY(t, []byte("\x1b]52;c;not-valid-base64!!\x07"))
286
287	tr, sends := newCapturingTransport(t)
288	sctx, cancel := context.WithCancel(context.Background())
289	defer cancel()
290	win := newTestTabWithContext(p, sctx, cancel)
291	ac := &attachedClient{tr: tr, output: newOutputStateStream()}
292	ac.initOverlays()
293	sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
294	publishTiledPaneOwners(sess, win)
295	ac.setSession(sess)
296	d.sessions[sess.id] = sess
297
298	d.sessWg.Add(1)
299	d.ptyReader(sess, win, win.focusedPane())
300
301	select {
302	case f := <-sends:
303		require.NotEqual(t, ports.MsgOutput, f.Type)
304	default:
305	}
306}
307
308func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
309	d := newTestDaemon(t, nil, stubClock{})
310	p := chunkReadPTY(t, []byte("\x1b]52;c;aGVsbG8=\x07"))
311
312	sctx, cancel := context.WithCancel(context.Background())
313	defer cancel()
314	win := newTestTabWithContext(p, sctx, cancel)
315	sess := &session{sessionCore: sessionCore{id: "clip-noclient", name: "clip-noclient"}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
316	publishTiledPaneOwners(sess, win)
317	d.sessions[sess.id] = sess
318
319	d.sessWg.Add(1)
320	require.NotPanics(t, func() {
321		d.ptyReader(sess, win, win.focusedPane())
322	})
323}
324
325type staleClipboardErrorTransport struct {
326	mu    sync.Mutex
327	sends int
328}
329
330func (t *staleClipboardErrorTransport) Send(ports.Frame) error {
331	t.mu.Lock()
332	t.sends++
333	t.mu.Unlock()
334	return errors.New("stale clipboard send")
335}
336
337func (*staleClipboardErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
338func (*staleClipboardErrorTransport) Close() error               { return nil }
339
340func (t *staleClipboardErrorTransport) sendCount() int {
341	t.mu.Lock()
342	defer t.mu.Unlock()
343	return t.sends
344}
345
346func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
347	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
348	oldTransport := &staleClipboardErrorTransport{}
349	ac.replaceTransport(oldTransport)
350	publishActiveClipboardCapability(d, sess, ac, oldTransport)
351
352	sess.mu.Lock()
353	sess.clipboardWorkerRunning = true
354	sess.mu.Unlock()
355	d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
356
357	sourceTab := testAttachmentTab(sess)
358	p := sourceTab.focusedPane()
359	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
360	publishPaneOwner(p, destination, &tab{}, 0)
361	publishPaneOwner(p, sess, sourceTab, 0)
362
363	d.clipboardWorker(sess)
364
365	require.Zero(t, oldTransport.sendCount(), "clipboard queued by the former pane owner reached its client")
366	require.Contains(t, sess.snapshotAttachments(), ac, "stale clipboard send handling detached the former owner's client")
367}
368
369type movingClipboardErrorTransport struct {
370	started     chan struct{}
371	release     chan struct{}
372	closed      chan struct{}
373	startedOnce sync.Once
374	closeOnce   sync.Once
375}
376
377func newMovingClipboardErrorTransport() *movingClipboardErrorTransport {
378	return &movingClipboardErrorTransport{
379		started: make(chan struct{}),
380		release: make(chan struct{}),
381		closed:  make(chan struct{}),
382	}
383}
384
385func (t *movingClipboardErrorTransport) Send(ports.Frame) error {
386	t.startedOnce.Do(func() { close(t.started) })
387	<-t.release
388	return errors.New("clipboard send failed after pane move")
389}
390
391func (*movingClipboardErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
392
393func (t *movingClipboardErrorTransport) Close() error {
394	t.closeOnce.Do(func() { close(t.closed) })
395	return nil
396}
397
398func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing.T) {
399	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
400	oldTransport := &staleClipboardErrorTransport{}
401	ac.replaceTransport(oldTransport)
402	publishActiveClipboardCapability(d, sess, ac, oldTransport)
403
404	sess.mu.Lock()
405	sess.clipboardWorkerRunning = true
406	sess.mu.Unlock()
407	d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
408
409	ac.sendMu.Lock()
410	sendMuLocked := true
411	defer func() {
412		if sendMuLocked {
413			ac.sendMu.Unlock()
414		}
415	}()
416	workerDone := make(chan struct{})
417	go func() {
418		d.clipboardWorker(sess)
419		close(workerDone)
420	}()
421	require.Eventually(t, func() bool {
422		return ac.attachmentEffects.inFlightCount() == 1
423	}, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
424
425	sourceTab := testAttachmentTab(sess)
426	p := sourceTab.focusedPane()
427	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
428	publishPaneOwner(p, destination, &tab{}, 0)
429	publishPaneOwner(p, sess, sourceTab, 0)
430	ac.sendMu.Unlock()
431	sendMuLocked = false
432	awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
433
434	require.Zero(t, oldTransport.sendCount(), "clipboard send was not revalidated immediately before transport I/O")
435	require.Contains(t, sess.snapshotAttachments(), ac)
436}
437
438func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
439	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
440	oldTransport := newMovingClipboardErrorTransport()
441	ac.replaceTransport(oldTransport)
442	publishActiveClipboardCapability(d, sess, ac, oldTransport)
443
444	sess.mu.Lock()
445	sess.clipboardWorkerRunning = true
446	sess.mu.Unlock()
447	d.forwardClipboardAsync(clipboardOwnerLease(t, sess), base64.StdEncoding.EncodeToString([]byte("queued")))
448
449	workerDone := make(chan struct{})
450	go func() {
451		d.clipboardWorker(sess)
452		close(workerDone)
453	}()
454	awaitTestCompletion(t, oldTransport.started, "clipboard send did not start")
455
456	sourceTab := testAttachmentTab(sess)
457	p := sourceTab.focusedPane()
458	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
459	publishPaneOwner(p, destination, &tab{}, 0)
460	publishPaneOwner(p, sess, sourceTab, 0)
461	close(oldTransport.release)
462	awaitTestCompletion(t, workerDone, "clipboard worker did not finish")
463
464	require.Contains(t, sess.snapshotAttachments(), ac, "a send error from the pane's retired owner detached its client")
465	select {
466	case <-oldTransport.closed:
467		t.Fatal("a send error from the pane's retired owner closed its client transport")
468	default:
469	}
470}
471
472func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
473	d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
474	tr := newBlockingClipboardTransport()
475	ac.replaceTransport(tr)
476	publishActiveClipboardCapability(d, sess, ac, tr)
477	owner := clipboardOwnerLease(t, sess)
478
479	first := base64.StdEncoding.EncodeToString([]byte("first"))
480	second := base64.StdEncoding.EncodeToString([]byte("second"))
481	d.forwardClipboardAsync(owner, first)
482	require.Equal(t, "first", <-tr.started)
483
484	d.forwardClipboardAsync(owner, second)
485	select {
486	case got := <-tr.started:
487		t.Fatalf("second clipboard send started before first completed: %q", got)
488	case <-time.After(50 * time.Millisecond):
489	}
490
491	close(tr.releaseFirst)
492	require.Equal(t, "first", <-tr.sent)
493	require.Equal(t, "second", <-tr.started)
494	require.Equal(t, "second", <-tr.sent)
495}
496
497type blockingClipboardTransport struct {
498	started      chan string
499	sent         chan string
500	releaseFirst chan struct{}
501}
502
503func newBlockingClipboardTransport() *blockingClipboardTransport {
504	return &blockingClipboardTransport{
505		started:      make(chan string, 2),
506		sent:         make(chan string, 2),
507		releaseFirst: make(chan struct{}),
508	}
509}
510
511func (tr *blockingClipboardTransport) Send(f ports.Frame) error {
512	out, err := ports.UnmarshalOutput(f.Payload)
513	if err != nil {
514		return err
515	}
516	data := string(out.Data)
517	var label string
518	switch {
519	case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("first"))):
520		label = "first"
521	case strings.Contains(data, base64.StdEncoding.EncodeToString([]byte("second"))):
522		label = "second"
523	default:
524		label = data
525	}
526	tr.started <- label
527	if label == "first" {
528		<-tr.releaseFirst
529	}
530	tr.sent <- label
531	return nil
532}
533
534func (tr *blockingClipboardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
535func (tr *blockingClipboardTransport) Close() error               { return nil }

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