context_menu_test.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"encoding/base64"
6	"errors"
7	"io"
8	"net"
9	"net/http"
10	"net/http/httptest"
11	"testing"
12	"unicode/utf8"
13
14	"github.com/bnema/dumber/internal/application/port"
15	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
16	"github.com/bnema/dumber/internal/application/usecase"
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/mock"
19	"github.com/stretchr/testify/require"
20)
21
22// stubHitTest simulates a WebKit HitTestResult for testing.
23type stubHitTest struct {
24	linkURI     string
25	imageURI    string
26	isLink      bool
27	isImage     bool
28	isEditable  bool
29	isSelection bool
30}
31
32func TestBuildMenuContextFromHitTest(t *testing.T) {
33	t.Run("image hit test populates ImageURI", func(t *testing.T) {
34		hit := stubHitTest{
35			imageURI: "https://example.com/photo.png",
36			isImage:  true,
37		}
38		ctx := buildMenuContextFromStubHitTest(hit, true, false, 100, 200)
39
40		assert.Equal(t, "https://example.com/photo.png", ctx.ImageURI)
41		assert.Equal(t, 100, ctx.X)
42		assert.Equal(t, 200, ctx.Y)
43		assert.True(t, ctx.CanGoBack)
44		assert.False(t, ctx.CanGoForward)
45	})
46
47	t.Run("link hit test populates LinkURI", func(t *testing.T) {
48		hit := stubHitTest{
49			linkURI: "https://example.com/page",
50			isLink:  true,
51		}
52		ctx := buildMenuContextFromStubHitTest(hit, false, true, 50, 75)
53
54		assert.Equal(t, "https://example.com/page", ctx.LinkURI)
55		assert.Empty(t, ctx.ImageURI)
56		assert.False(t, ctx.CanGoBack)
57		assert.True(t, ctx.CanGoForward)
58	})
59
60	t.Run("editable and selection flags propagate", func(t *testing.T) {
61		hit := stubHitTest{
62			isEditable:  true,
63			isSelection: true,
64		}
65		ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
66
67		assert.True(t, ctx.IsEditable)
68		assert.True(t, ctx.HasSelection)
69	})
70
71	t.Run("combined image and link", func(t *testing.T) {
72		hit := stubHitTest{
73			linkURI:  "https://example.com/link",
74			imageURI: "https://example.com/img.jpg",
75			isLink:   true,
76			isImage:  true,
77		}
78		ctx := buildMenuContextFromStubHitTest(hit, true, true, 10, 20)
79
80		assert.Equal(t, "https://example.com/link", ctx.LinkURI)
81		assert.Equal(t, "https://example.com/img.jpg", ctx.ImageURI)
82		assert.True(t, ctx.CanGoBack)
83		assert.True(t, ctx.CanGoForward)
84	})
85
86	t.Run("empty hit test produces empty context", func(t *testing.T) {
87		hit := stubHitTest{}
88		ctx := buildMenuContextFromStubHitTest(hit, false, false, 0, 0)
89
90		assert.Empty(t, ctx.LinkURI)
91		assert.Empty(t, ctx.ImageURI)
92		assert.False(t, ctx.HasSelection)
93		assert.False(t, ctx.IsEditable)
94	})
95}
96
97func TestResolveImageData(t *testing.T) {
98	t.Run("rejects empty image URI", func(t *testing.T) {
99		resolver := &contextMenuResolver{}
100		_, err := resolver.ResolveImageData(context.Background(), "")
101		require.Error(t, err)
102		assert.Contains(t, err.Error(), "empty image URI")
103	})
104
105	t.Run("rejects unsupported URI scheme", func(t *testing.T) {
106		resolver := &contextMenuResolver{}
107		_, err := resolver.ResolveImageData(context.Background(), "file:///tmp/image.png")
108		require.Error(t, err)
109		assert.Contains(t, err.Error(), "unsupported URI scheme")
110	})
111
112	t.Run("decodes data URI images", func(t *testing.T) {
113		imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
114		uri := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes)
115
116		resolver := &contextMenuResolver{}
117		data, err := resolver.ResolveImageData(context.Background(), uri)
118		require.NoError(t, err)
119		assert.Equal(t, imageBytes, data.Bytes)
120		assert.Equal(t, "image/png", data.MimeType)
121	})
122
123	t.Run("fetches image bytes from HTTP", func(t *testing.T) {
124		imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
125		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
126			w.Header().Set("Content-Type", "image/jpeg; charset=utf-8")
127			w.Write(imageBytes)
128		}))
129		defer srv.Close()
130
131		resolver := &contextMenuResolver{allowPrivateHosts: true}
132		data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
133		require.NoError(t, err)
134		assert.Equal(t, imageBytes, data.Bytes)
135		assert.Equal(t, "image/png", data.MimeType)
136	})
137
138	t.Run("rejects DNS lookup errors", func(t *testing.T) {
139		resolver := &contextMenuResolver{
140			lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
141				return nil, errors.New("dns failed")
142			},
143		}
144
145		_, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
146		require.Error(t, err)
147		assert.Contains(t, err.Error(), "dns failed")
148	})
149
150	t.Run("falls back to sniffed MIME type when header is generic", func(t *testing.T) {
151		imageBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
152		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
153			w.Header().Set("Content-Type", "application/octet-stream")
154			w.Write(imageBytes)
155		}))
156		defer srv.Close()
157
158		resolver := &contextMenuResolver{allowPrivateHosts: true}
159		data, err := resolver.ResolveImageData(context.Background(), srv.URL+"/image.png")
160		require.NoError(t, err)
161		assert.Equal(t, imageBytes, data.Bytes)
162		assert.Equal(t, "image/png", data.MimeType)
163	})
164
165	t.Run("rejects DNS-resolved private hosts", func(t *testing.T) {
166		resolver := &contextMenuResolver{
167			lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
168				return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
169			},
170		}
171
172		_, err := resolver.ResolveImageData(context.Background(), "https://image.example/photo.png")
173		require.Error(t, err)
174		assert.Contains(t, err.Error(), "private host")
175	})
176
177	t.Run("connect-time dial skips private addresses and uses public ones", func(t *testing.T) {
178		var dialed []string
179		resolver := &contextMenuResolver{
180			lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
181				return []net.IPAddr{
182					{IP: net.ParseIP("127.0.0.1")},
183					{IP: net.ParseIP("93.184.216.34")},
184				}, nil
185			},
186			dialContext: func(_ context.Context, _ string, addr string) (net.Conn, error) {
187				dialed = append(dialed, addr)
188				conn, peer := net.Pipe()
189				t.Cleanup(func() {
190					_ = conn.Close()
191					_ = peer.Close()
192				})
193				return conn, nil
194			},
195		}
196
197		conn, err := resolver.dialImageHostContext(context.Background(), "tcp", "image.example:443")
198		require.NoError(t, err)
199		require.NotNil(t, conn)
200		assert.Equal(t, []string{"93.184.216.34:443"}, dialed)
201	})
202
203	t.Run("rejects private literal addresses at connect time", func(t *testing.T) {
204		resolver := &contextMenuResolver{}
205
206		_, err := resolver.dialImageHostContext(context.Background(), "tcp", "127.0.0.1:443")
207		require.Error(t, err)
208		assert.Contains(t, err.Error(), "private host")
209	})
210
211	t.Run("redirect check rejects private targets", func(t *testing.T) {
212		resolver := &contextMenuResolver{
213			lookupIPAddr: func(context.Context, string) ([]net.IPAddr, error) {
214				return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
215			},
216		}
217
218		req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://redirect.example/photo.png", http.NoBody)
219		require.NoError(t, err)
220		err = resolver.imageFetchClient(context.Background()).CheckRedirect(req, nil)
221		require.Error(t, err)
222		assert.Contains(t, err.Error(), "private host")
223	})
224
225	t.Run("rejects non-image HTTP content", func(t *testing.T) {
226		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
227			w.Header().Set("Content-Type", "text/html")
228			w.Write([]byte("<html><body>not an image</body></html>"))
229		}))
230		defer srv.Close()
231
232		resolver := &contextMenuResolver{allowPrivateHosts: true}
233		_, err := resolver.ResolveImageData(context.Background(), srv.URL+"/page.html")
234		require.Error(t, err)
235		assert.Contains(t, err.Error(), "not an image")
236	})
237
238	t.Run("rejects spoofed image headers when sniffed bytes are not an image", func(t *testing.T) {
239		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
240			w.Header().Set("Content-Type", "image/png")
241			w.Write([]byte("<html><body>not really an image</body></html>"))
242		}))
243		defer srv.Close()
244
245		resolver := &contextMenuResolver{allowPrivateHosts: true}
246		_, err := resolver.ResolveImageData(context.Background(), srv.URL+"/spoofed.png")
247		require.Error(t, err)
248		assert.Contains(t, err.Error(), "not an image")
249		assert.Contains(t, err.Error(), "text/html")
250		assert.Contains(t, err.Error(), "image/png")
251		assert.Contains(t, err.Error(), "/spoofed.png")
252	})
253
254	t.Run("returns error on HTTP failure", func(t *testing.T) {
255		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
256			w.WriteHeader(http.StatusNotFound)
257		}))
258		defer srv.Close()
259
260		resolver := &contextMenuResolver{allowPrivateHosts: true}
261		_, err := resolver.ResolveImageData(context.Background(), srv.URL+"/missing.png")
262		require.Error(t, err)
263		assert.Contains(t, err.Error(), "404")
264	})
265
266	t.Run("rejects oversized bodies", func(t *testing.T) {
267		resolver := &contextMenuResolver{
268			allowPrivateHosts: true,
269			client: &http.Client{
270				Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
271					return &http.Response{
272						StatusCode: http.StatusOK,
273						Header:     http.Header{"Content-Type": []string{"image/png"}},
274						Body:       io.NopCloser(newSizedByteReader(maxImageFetchBytes + 1)),
275					}, nil
276				}),
277			},
278		}
279
280		_, err := resolver.ResolveImageData(context.Background(), "https://example.com/image.png")
281		require.Error(t, err)
282		assert.Contains(t, err.Error(), "too large")
283	})
284
285	t.Run("returns error on invalid URL", func(t *testing.T) {
286		resolver := &contextMenuResolver{}
287		_, err := resolver.ResolveImageData(context.Background(), "://bad-url")
288		require.Error(t, err)
289	})
290}
291
292func TestWebkitMenuDelegator_OpenLinkNewTab(t *testing.T) {
293	var gotURI string
294	wv := &WebView{
295		OnLinkMiddleClick: func(uri string) bool {
296			gotURI = uri
297			return true
298		},
299	}
300	delegator := &webkitMenuDelegator{wv: wv}
301
302	err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
303	require.NoError(t, err)
304	require.Equal(t, "https://example.com/new", gotURI)
305}
306
307func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) {
308	wv := &WebView{
309		OnLinkMiddleClick: func(string) bool {
310			return false
311		},
312	}
313	delegator := &webkitMenuDelegator{wv: wv}
314
315	err := delegator.DelegateMenuAction(context.Background(), port.MenuActionOpenLinkNewTab, port.MenuContext{LinkURI: "https://example.com/new"})
316	require.Error(t, err)
317	assert.Contains(t, err.Error(), "action not handled")
318}
319
320func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) {
321	var copiedLen int
322	ctx := context.Background()
323	baseClipboard := portmocks.NewMockClipboard(t)
324	factory := portmocks.NewMockContextMenuActionExecutorFactory(t)
325	var capturedClipboard port.Clipboard
326	factory.EXPECT().NewContextMenuActionExecutor(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
327		func(
328			clipboard port.Clipboard,
329			resolver port.ImageDataResolver,
330			saver port.ResolvedImageSaver,
331			delegator port.MenuActionDelegator,
332		) port.ContextMenuActionExecutor {
333			capturedClipboard = clipboard
334			return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
335		},
336	).Once()
337	pipeline := &contextMenuPipeline{
338		clipboard:       baseClipboard,
339		onCopied:        func(textLen int) { copiedLen = textLen },
340		executorFactory: factory,
341	}
342
343	executor := pipeline.newExecutor(&WebView{})
344	require.NotNil(t, executor)
345
346	t.Run("copy link", func(t *testing.T) {
347		copiedLen = 0
348		require.NotNil(t, capturedClipboard)
349		baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once()
350
351		err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"})
352		require.NoError(t, err)
353		require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen)
354	})
355
356	t.Run("copy selection", func(t *testing.T) {
357		copiedLen = 0
358		require.NotNil(t, capturedClipboard)
359		baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once()
360
361		err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"})
362		require.NoError(t, err)
363		require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen)
364	})
365}
366
367// buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
368// that works with our stub type instead of requiring real WebKit objects.
369func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
370	ctx := port.MenuContext{
371		CanGoBack:    canGoBack,
372		CanGoForward: canGoForward,
373		X:            x,
374		Y:            y,
375	}
376
377	if hit.isLink {
378		ctx.LinkURI = hit.linkURI
379	}
380	if hit.isImage {
381		ctx.ImageURI = hit.imageURI
382	}
383	ctx.HasSelection = hit.isSelection
384	ctx.IsEditable = hit.isEditable
385
386	return ctx
387}
388
389type roundTripperFunc func(*http.Request) (*http.Response, error)
390
391func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
392	return f(req)
393}
394
395type sizedByteReader struct {
396	remaining int
397}
398
399func newSizedByteReader(size int) *sizedByteReader {
400	return &sizedByteReader{remaining: size}
401}
402
403func (r *sizedByteReader) Read(p []byte) (int, error) {
404	if r.remaining <= 0 {
405		return 0, io.EOF
406	}
407	n := min(len(p), r.remaining)
408	for i := range n {
409		p[i] = 'x'
410	}
411	r.remaining -= n
412	return n, nil
413}

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 8

Tags 42

↑↓ moveenter openesc close