v0.3.0 99dc266

clipboard_intercept_test.go RawHistoryFolder

1package client_test
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"io"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/vev/internal/domain"
15	"github.com/bnema/vev/internal/ports"
16	portsmocks "github.com/bnema/vev/internal/ports/mocks"
17	"github.com/bnema/vev/internal/usecase/client"
18)
19
20// clipboardRunTestDeps wires a Run() attempt with an input reader that
21// delivers data then blocks (so the pump stays parked until detach), a
22// transport that captures every MsgInput/MsgImagePush send, and a Recv that
23// answers Welcome then, once triggered, Detached — mirroring the pattern used
24// by the OSC-color-response test elsewhere in this package.
25func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) {
26	t.Helper()
27	var out bytes.Buffer
28	var restoreCount int32
29	resizeCh := make(chan domain.Size)
30	input := newOneShotBlockingReader(stdin)
31	defer input.unblock()
32
33	tm := portsmocks.NewMockTerminal(t)
34	tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
35	tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
36	tm.EXPECT().In().Return(input).Maybe()
37	tm.EXPECT().Out().Return(&out).Maybe()
38	tm.EXPECT().Flush().Return(nil).Maybe()
39	tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
40
41	gotInput = make(chan []byte, 8)
42	gotImage = make(chan ports.ImagePush, 1)
43	allowDetach := make(chan struct{})
44
45	tr := portsmocks.NewMockTransport(t)
46	tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
47	tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
48	tr.EXPECT().Send(isType(ports.MsgResize)).Return(nil).Maybe()
49	tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
50		in, err := ports.UnmarshalInput(f.Payload)
51		require.NoError(t, err)
52		gotInput <- append([]byte(nil), in.Data...)
53		if bytes.IndexByte(in.Data, triggerByte) >= 0 {
54			closeOnce(allowDetach)
55		}
56		return nil
57	}).Maybe()
58	tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error {
59		ip, err := ports.UnmarshalImagePush(f.Payload)
60		require.NoError(t, err)
61		gotImage <- ip
62		return nil
63	}).Maybe()
64	tr.EXPECT().Send(isType(ports.MsgClientNotice)).Return(nil).Maybe()
65
66	welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
67	detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
68	recvCh := make(chan recvItem, 1)
69	recvCh <- recvItem{f: welcome}
70	closed := make(chan struct{})
71	tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
72		select {
73		case it := <-recvCh:
74			return it.f, it.err
75		case <-allowDetach:
76			select {
77			case <-closed:
78				return ports.Frame{}, io.EOF
79			default:
80				close(closed)
81				return detached, nil
82			}
83		case <-closed:
84			return ports.Frame{}, io.EOF
85		}
86	}).Maybe()
87	tr.EXPECT().Close().Return(nil).Once()
88
89	d := portsmocks.NewMockDialer(t)
90	d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
91
92	err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, clip, nil), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: remote})
93	require.NoError(t, err)
94	return gotInput, gotImage
95}
96
97func closeOnce(ch chan struct{}) {
98	select {
99	case <-ch:
100	default:
101		close(ch)
102	}
103}
104
105func drainInput(gotInput chan []byte) []byte {
106	var b []byte
107	for {
108		select {
109		case chunk := <-gotInput:
110			b = append(b, chunk...)
111		default:
112			return b
113		}
114	}
115}
116
117func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) {
118	clip := portsmocks.NewMockClipboardReader(t)
119	clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once()
120
121	gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
122
123	select {
124	case ip := <-gotImage:
125		require.Equal(t, uint64(2), ip.InputSeq)
126		require.Equal(t, "image/png", ip.Mime)
127		require.Equal(t, []byte("PNGDATA"), ip.Data)
128	case <-time.After(2 * time.Second):
129		t.Fatal("MsgImagePush was not sent")
130	}
131	require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent")
132}
133
134func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
135	clip := portsmocks.NewMockClipboardReader(t)
136	clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
137
138	gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
139
140	require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
141}
142
143type clipboardToastLifecycleTransport struct {
144	recv  chan ports.Frame
145	sends chan ports.Frame
146}
147
148func (t *clipboardToastLifecycleTransport) Send(f ports.Frame) error {
149	t.sends <- f
150	return nil
151}
152
153func (t *clipboardToastLifecycleTransport) Recv() (ports.Frame, error) { return <-t.recv, nil }
154func (*clipboardToastLifecycleTransport) Close() error                 { return nil }
155
156type clipboardToastLifecycleDialer struct{ transport ports.Transport }
157
158func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, error) {
159	return d.transport, nil
160}
161
162func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) {
163	var out bytes.Buffer
164	resizeEvents := make(chan domain.Size)
165	input := newOneShotBlockingReader([]byte{0x16})
166	defer input.unblock()
167	term := portsmocks.NewMockTerminal(t)
168	term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
169	term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once()
170	term.EXPECT().In().Return(input).Maybe()
171	term.EXPECT().Out().Return(&out).Maybe()
172	term.EXPECT().Flush().Return(nil).Maybe()
173	term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(resizeEvents)).Maybe()
174
175	clipboard := portsmocks.NewMockClipboardReader(t)
176	clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once()
177	transport := &clipboardToastLifecycleTransport{recv: make(chan ports.Frame, 8), sends: make(chan ports.Frame, 16)}
178	transport.recv <- frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
179
180	result := make(chan error, 1)
181	go func() {
182		result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true})
183	}()
184
185	var gotNotice bool
186	for !gotNotice {
187		select {
188		case sent := <-transport.sends:
189			if sent.Type != ports.MsgClientNotice {
190				continue
191			}
192			notice, err := ports.UnmarshalClientNotice(sent.Payload)
193			require.NoError(t, err)
194			require.Equal(t, ports.ClientNoticeClipboardFallback, notice.Action)
195			gotNotice = true
196		case <-time.After(time.Second):
197			t.Fatal("client did not notify daemon of clipboard failure")
198		}
199	}
200	beforeOutput := out.String()
201	transport.recv <- frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("incremental")}))
202	transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
203
204	select {
205	case err := <-result:
206		require.NoError(t, err)
207	case <-time.After(time.Second):
208		t.Fatal("client did not finish")
209	}
210	require.Equal(t, beforeOutput+"incremental", out.String(), "daemon output must remain verbatim with no local toast bytes")
211}
212
213func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
214	huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
215	clip := portsmocks.NewMockClipboardReader(t)
216	clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
217
218	gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
219
220	require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead")
221}
222
223func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) {
224	// A MockClipboardReader with no .EXPECT() set fails the test if
225	// ReadImage is ever called — which must never happen for a 0x16 that
226	// arrives as part of pasted content, even when the whole paste (open
227	// marker, content, close marker) lands in a single read alongside it.
228	clip := portsmocks.NewMockClipboardReader(t)
229
230	paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z")
231	gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z')
232
233	require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit")
234	select {
235	case ip := <-gotImage:
236		t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip)
237	default:
238	}
239}
240
241func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) {
242	// A MockClipboardReader with no .EXPECT() set fails the test if ReadImage
243	// is ever called, which is exactly what a local (non-remote) attach must
244	// never do.
245	clip := portsmocks.NewMockClipboardReader(t)
246
247	gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b')
248
249	require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched")
250}

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