v0.31.0 bf0756b

browser_launch_relay_test.go RawHistoryFolder

1package desktop
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"net"
8	"os"
9	"path/filepath"
10	"testing"
11	"time"
12
13	"github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16)
17
18type browserWindowOpenerFunc func(context.Context, string) error
19
20func (f browserWindowOpenerFunc) OpenExternalURL(ctx context.Context, url string) error {
21	return f(ctx, url)
22}
23
24func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
25	return f(ctx, url)
26}
27
28type browserWindowOpener struct {
29	openExternalURL func(context.Context, string) error
30	openFreshWindow func(context.Context, string) error
31}
32
33func (o browserWindowOpener) OpenExternalURL(ctx context.Context, url string) error {
34	return o.openExternalURL(ctx, url)
35}
36
37func (o browserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {
38	return o.openFreshWindow(ctx, url)
39}
40
41func waitForSocket(t *testing.T, path string) {
42	t.Helper()
43
44	require.Eventually(t, func() bool {
45		_, err := os.Stat(path)
46		return err == nil
47	}, time.Second, 10*time.Millisecond)
48}
49
50func waitForSocketGone(t *testing.T, path string) {
51	t.Helper()
52
53	require.Eventually(t, func() bool {
54		_, err := os.Stat(path)
55		return os.IsNotExist(err)
56	}, time.Second, 10*time.Millisecond)
57}
58
59func shortTempDir(t *testing.T) string {
60	t.Helper()
61
62	dir, err := os.MkdirTemp("", "dbr-")
63	require.NoError(t, err)
64	t.Cleanup(func() {
65		_ = os.RemoveAll(dir)
66	})
67
68	return dir
69}
70
71func TestValidateBrowserLaunchSocketDirOwnedAcceptsCurrentUser(t *testing.T) {
72	ipc := testIPC(shortTempDir(t))
73	require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
74
75	require.NoError(t, validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid())))
76}
77
78func TestValidateBrowserLaunchSocketDirOwnedRejectsUnexpectedOwner(t *testing.T) {
79	ipc := testIPC(shortTempDir(t))
80	require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
81
82	unexpectedUID := uint32(os.Geteuid() + 1)
83	if unexpectedUID == uint32(os.Geteuid()) {
84		unexpectedUID++
85	}
86
87	err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, unexpectedUID)
88	require.Error(t, err)
89	require.Contains(t, err.Error(), "browser launch dir owner")
90}
91
92func TestValidateBrowserLaunchSocketDirOwnedRejectsWritableUserParent(t *testing.T) {
93	root := shortTempDir(t)
94	ipc := runtimeprofile.IPCPaths{
95		RuntimeDir:          filepath.Join(root, "dumber", "dev-abc123", "cef"),
96		BrowserLaunchSocket: filepath.Join(root, "dumber", "dev-abc123", "cef", browserLaunchSocketName),
97	}
98	require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
99	require.NoError(t, os.Chmod(filepath.Join(root, "dumber"), 0o777))
100	t.Cleanup(func() {
101		_ = os.Chmod(filepath.Join(root, "dumber"), 0o700)
102	})
103
104	err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid()))
105	require.Error(t, err)
106	require.Contains(t, err.Error(), "browser launch dir permissions")
107}
108
109func testIPC(root string) runtimeprofile.IPCPaths {
110	runtimeDir := filepath.Join(root, "runtime")
111	return runtimeprofile.IPCPaths{
112		RuntimeDir:          runtimeDir,
113		BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
114	}
115}
116
117func testNamespacedIPC(root, engine string) runtimeprofile.IPCPaths {
118	runtimeDir := filepath.Join(root, "runtime", engine)
119	return runtimeprofile.IPCPaths{
120		RuntimeDir:          runtimeDir,
121		BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
122	}
123}
124
125func overrideBrowserLaunchRequestID(t *testing.T, id string) func() {
126	t.Helper()
127	previous := newBrowserLaunchRequestID
128	newBrowserLaunchRequestID = func() string { return id }
129	return func() { newBrowserLaunchRequestID = previous }
130}
131
132func TestBrowserLaunchRelay_DeliverOpenExternalURL_MissingListenerReturnsFalseNil(t *testing.T) {
133	relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
134
135	delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com")
136
137	require.NoError(t, err)
138	assert.False(t, delivered)
139}
140
141func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
142	ipc := testNamespacedIPC(shortTempDir(t), "cef")
143	listenerRelay := NewBrowserLaunchRelay(ipc)
144	clientRelay := NewBrowserLaunchRelay(ipc)
145
146	received := make(chan string, 1)
147	ctx := t.Context()
148
149	closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
150		received <- url
151		return nil
152	}))
153	require.NoError(t, err)
154	defer closer.Close()
155
156	waitForSocket(t, ipc.BrowserLaunchSocket)
157
158	delivered, err := clientRelay.DeliverOpenExternalURL(context.Background(), "https://example.com/same-namespace")
159	require.NoError(t, err)
160	require.True(t, delivered)
161	require.Equal(t, "https://example.com/same-namespace", <-received)
162}
163
164func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) {
165	root := t.TempDir()
166	cefIPC := testNamespacedIPC(root, "cef")
167	wkIPC := testNamespacedIPC(root, "webkit")
168	require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
169}
170
171func TestBrowserLaunchRelay_DeliverOpenExternalURL_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
172	ipc := testIPC(shortTempDir(t))
173	relay := NewBrowserLaunchRelay(ipc)
174	restore := overrideBrowserLaunchRequestID(t, "request-test-1")
175	defer restore()
176
177	receivedRequest := make(chan browserLaunchRequest, 1)
178	require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
179	listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
180	require.NoError(t, err)
181	defer func() {
182		_ = listener.Close()
183		_ = os.Remove(ipc.BrowserLaunchSocket)
184	}()
185
186	go func() {
187		conn, acceptErr := listener.AcceptUnix()
188		if acceptErr != nil {
189			return
190		}
191		defer conn.Close()
192		var request browserLaunchRequest
193		if decodeErr := json.NewDecoder(conn).Decode(&request); decodeErr != nil {
194			return
195		}
196		receivedRequest <- request
197		_ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})
198	}()
199
200	delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id")
201
202	require.NoError(t, err)
203	require.True(t, delivered)
204	request := <-receivedRequest
205	require.Equal(t, "request-test-1", request.RequestID)
206	require.Equal(t, "https://example.com/request-id", request.URL)
207}
208
209func TestBrowserLaunchRelay_DeliverOpenExternalURL_RejectsMismatchedResponseRequestID(t *testing.T) {
210	ipc := testIPC(shortTempDir(t))
211	relay := NewBrowserLaunchRelay(ipc)
212	restore := overrideBrowserLaunchRequestID(t, "request-test-2")
213	defer restore()
214
215	require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
216	listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
217	require.NoError(t, err)
218	defer func() {
219		_ = listener.Close()
220		_ = os.Remove(ipc.BrowserLaunchSocket)
221	}()
222
223	go func() {
224		conn, acceptErr := listener.AcceptUnix()
225		if acceptErr != nil {
226			return
227		}
228		defer conn.Close()
229		var request browserLaunchRequest
230		if decodeErr := json.NewDecoder(conn).Decode(&request); decodeErr != nil {
231			return
232		}
233		_ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})
234	}()
235
236	delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id-mismatch")
237
238	require.Error(t, err)
239	require.True(t, delivered)
240	require.Contains(t, err.Error(), "mismatched browser launch relay response")
241}
242
243func TestBrowserLaunchRelay_DeliverOpenExternalURL_RoundTrip(t *testing.T) {
244	ipc := testIPC(shortTempDir(t))
245	relay := NewBrowserLaunchRelay(ipc)
246
247	received := make(chan string, 1)
248	ctx := t.Context()
249
250	closer, err := relay.Listen(ctx, browserWindowOpener{
251		openExternalURL: func(_ context.Context, url string) error {
252			received <- url
253			return nil
254		},
255		openFreshWindow: func(context.Context, string) error {
256			t.Fatal("external URL relay request must preserve configured placement")
257			return nil
258		},
259	})
260	require.NoError(t, err)
261	defer closer.Close()
262
263	waitForSocket(t, ipc.BrowserLaunchSocket)
264
265	delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/new")
266
267	require.NoError(t, err)
268	assert.True(t, delivered)
269
270	select {
271	case got := <-received:
272		assert.Equal(t, "https://example.com/new", got)
273	case <-time.After(time.Second):
274		t.Fatal("expected opener to receive the URL")
275	}
276}
277
278func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
279	ipc := testIPC(shortTempDir(t))
280	relay := NewBrowserLaunchRelay(ipc)
281
282	freshWindows := make(chan string, 1)
283	ctx := t.Context()
284	closer, err := relay.Listen(ctx, browserWindowOpener{
285		openExternalURL: func(context.Context, string) error {
286			t.Fatal("external-link placement must not handle standalone omnibox navigation")
287			return nil
288		},
289		openFreshWindow: func(_ context.Context, url string) error {
290			freshWindows <- url
291			return nil
292		},
293	})
294	require.NoError(t, err)
295	defer closer.Close()
296
297	waitForSocket(t, ipc.BrowserLaunchSocket)
298	delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/omnibox")
299
300	require.NoError(t, err)
301	require.True(t, delivered)
302	select {
303	case got := <-freshWindows:
304		assert.Equal(t, "https://example.com/omnibox", got)
305	case <-time.After(time.Second):
306		t.Fatal("expected fresh-window opener to receive the URL")
307	}
308}
309
310func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) {
311	relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{})
312
313	_, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
314	require.Error(t, err)
315	require.Contains(t, err.Error(), "browser launch socket path")
316}
317
318func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {
319	ipc := testIPC(shortTempDir(t))
320	relay := NewBrowserLaunchRelay(ipc)
321
322	firstReceived := make(chan string, 1)
323	ctx := t.Context()
324
325	closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
326		firstReceived <- url
327		return nil
328	}))
329	require.NoError(t, err)
330	defer closer.Close()
331
332	waitForSocket(t, ipc.BrowserLaunchSocket)
333
334	_, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
335	require.Error(t, err)
336
337	delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/live")
338	require.NoError(t, err)
339	require.True(t, delivered)
340
341	select {
342	case got := <-firstReceived:
343		assert.Equal(t, "https://example.com/live", got)
344	case <-time.After(time.Second):
345		t.Fatal("expected the original listener to remain active")
346	}
347}
348
349func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
350	ipc := testIPC(shortTempDir(t))
351	relay := NewBrowserLaunchRelay(ipc)
352
353	require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
354	require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600))
355
356	received := make(chan string, 1)
357	ctx := t.Context()
358
359	closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
360		received <- url
361		return nil
362	}))
363	require.NoError(t, err)
364	defer closer.Close()
365
366	waitForSocket(t, ipc.BrowserLaunchSocket)
367
368	delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/stale")
369	require.NoError(t, err)
370	require.True(t, delivered)
371
372	select {
373	case got := <-received:
374		assert.Equal(t, "https://example.com/stale", got)
375	case <-time.After(time.Second):
376		t.Fatal("expected stale socket path to be rebound")
377	}
378}
379
380func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
381	ipc := testIPC(shortTempDir(t))
382	relay := NewBrowserLaunchRelay(ipc)
383
384	received := make(chan string, 1)
385	ctx := t.Context()
386
387	closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
388		received <- url
389		return nil
390	}))
391	require.NoError(t, err)
392	defer closer.Close()
393
394	waitForSocket(t, ipc.BrowserLaunchSocket)
395
396	conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
397	require.NoError(t, err)
398
399	_, err = conn.Write([]byte("not-json"))
400	require.NoError(t, err)
401	require.NoError(t, conn.Close())
402
403	delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/still-works")
404
405	require.NoError(t, err)
406	assert.True(t, delivered)
407
408	select {
409	case got := <-received:
410		assert.Equal(t, "https://example.com/still-works", got)
411	case <-time.After(time.Second):
412		t.Fatal("expected opener to receive the URL after malformed payload")
413	}
414}
415
416func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
417	ipc := testIPC(shortTempDir(t))
418	relay := NewBrowserLaunchRelay(ipc)
419
420	ctx := t.Context()
421
422	closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
423	require.NoError(t, err)
424
425	waitForSocket(t, ipc.BrowserLaunchSocket)
426
427	require.NoError(t, closer.Close())
428	waitForSocketGone(t, ipc.BrowserLaunchSocket)
429}
430
431func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
432	ipc := testIPC(shortTempDir(t))
433	relay := NewBrowserLaunchRelay(ipc)
434
435	ctx, cancel := context.WithCancel(context.Background())
436	closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
437	require.NoError(t, err)
438
439	waitForSocket(t, ipc.BrowserLaunchSocket)
440
441	cancel()
442	waitForSocketGone(t, ipc.BrowserLaunchSocket)
443	require.NoError(t, closer.Close())
444
445	delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/after-cancel")
446	require.NoError(t, err)
447	assert.False(t, delivered)
448}
449
450func TestBrowserLaunchRelay_DeliverOpenExternalURL_RespectsResponseReadTimeout(t *testing.T) {
451	ipc := testIPC(shortTempDir(t))
452	require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
453
454	listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
455	require.NoError(t, err)
456	defer func() {
457		_ = listener.Close()
458		_ = os.Remove(ipc.BrowserLaunchSocket)
459	}()
460
461	ready := make(chan struct{})
462	go func() {
463		close(ready)
464		conn, acceptErr := listener.AcceptUnix()
465		if acceptErr != nil {
466			return
467		}
468		defer conn.Close()
469		<-time.After(time.Second)
470	}()
471	<-ready
472
473	relay := NewBrowserLaunchRelay(ipc)
474	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
475	defer cancel()
476
477	delivered, err := relay.DeliverOpenExternalURL(ctx, "https://example.com/slow")
478	require.Error(t, err)
479	require.ErrorIs(t, err, context.DeadlineExceeded)
480	assert.False(t, delivered)
481}
482
483func TestBrowserLaunchRelay_DeliverOpenExternalURL_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
484	ipc := testIPC(shortTempDir(t))
485	require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
486
487	listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
488	require.NoError(t, err)
489	defer func() {
490		_ = listener.Close()
491		_ = os.Remove(ipc.BrowserLaunchSocket)
492	}()
493
494	ready := make(chan struct{})
495	go func() {
496		close(ready)
497		conn, acceptErr := listener.AcceptUnix()
498		if acceptErr != nil {
499			return
500		}
501		defer conn.Close()
502		<-time.After(time.Second)
503	}()
504	<-ready
505
506	relay := NewBrowserLaunchRelay(ipc)
507
508	started := make(chan struct{})
509
510	type deliverResult struct {
511		delivered bool
512		err       error
513	}
514	result := make(chan deliverResult, 1)
515	go func() {
516		close(started)
517		delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/slow")
518		result <- deliverResult{delivered: delivered, err: err}
519	}()
520	<-started
521
522	select {
523	case got := <-result:
524		assert.True(t, got.delivered)
525		require.Error(t, got.err)
526		require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)
527	case <-time.After(browserLaunchIOTimeout * 3):
528		t.Fatal("DeliverOpenExternalURL blocked without a caller deadline")
529	}
530}
531
532func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
533	ipc := testIPC(shortTempDir(t))
534	relay := NewBrowserLaunchRelay(ipc)
535
536	started := make(chan struct{})
537	release := make(chan struct{})
538	ctx := t.Context()
539
540	closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
541		close(started)
542		<-release
543		return nil
544	}))
545	require.NoError(t, err)
546	defer closer.Close()
547
548	waitForSocket(t, ipc.BrowserLaunchSocket)
549
550	conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
551	require.NoError(t, err)
552	defer conn.Close()
553	require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
554	require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "raw-request-1", URL: "https://example.com/slow-but-healthy"}))
555
556	var response browserLaunchResponse
557	require.NoError(t, json.NewDecoder(conn).Decode(&response))
558	assert.Empty(t, response.Error)
559	assert.True(t, response.Accepted)
560	assert.Equal(t, "raw-request-1", response.RequestID)
561
562	select {
563	case <-started:
564	case <-time.After(time.Second):
565		t.Fatal("expected opener to be invoked after acknowledgement")
566	}
567	close(release)
568}
569
570func TestBrowserLaunchRelay_AcknowledgesAcceptedEvenWhenOpenerReturnsLateError(t *testing.T) {
571	ipc := testIPC(shortTempDir(t))
572	relay := NewBrowserLaunchRelay(ipc)
573
574	started := make(chan struct{})
575	ctx := t.Context()
576
577	closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
578		close(started)
579		return errors.New("late open failure")
580	}))
581	require.NoError(t, err)
582	defer closer.Close()
583
584	waitForSocket(t, ipc.BrowserLaunchSocket)
585
586	conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
587	require.NoError(t, err)
588	defer conn.Close()
589	require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
590	require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "raw-request-2", URL: "https://example.com/fails-later"}))
591
592	var response browserLaunchResponse
593	require.NoError(t, json.NewDecoder(conn).Decode(&response))
594	assert.Empty(t, response.Error)
595	assert.True(t, response.Accepted)
596	assert.Equal(t, "raw-request-2", response.RequestID)
597
598	select {
599	case <-started:
600	case <-time.After(time.Second):
601		t.Fatal("expected opener to be invoked after accepted acknowledgement")
602	}
603}
604
605func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
606	ipc := testIPC(shortTempDir(t))
607	relay := NewBrowserLaunchRelay(ipc)
608
609	received := make(chan string, 1)
610	ctx := t.Context()
611
612	closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
613		received <- url
614		return nil
615	}))
616	require.NoError(t, err)
617	defer closer.Close()
618
619	waitForSocket(t, ipc.BrowserLaunchSocket)
620
621	conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
622	require.NoError(t, err)
623	defer conn.Close()
624	time.Sleep(20 * time.Millisecond)
625
626	type deliverResult struct {
627		delivered bool
628		err       error
629	}
630	result := make(chan deliverResult, 1)
631	go func() {
632		delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/recovered")
633		result <- deliverResult{delivered: delivered, err: err}
634	}()
635
636	select {
637	case got := <-result:
638		require.NoError(t, got.err)
639		require.True(t, got.delivered)
640	case <-time.After(300 * time.Millisecond):
641		t.Fatal("silent client stalled the relay listener")
642	}
643
644	select {
645	case got := <-received:
646		assert.Equal(t, "https://example.com/recovered", got)
647	case <-time.After(time.Second):
648		t.Fatal("expected opener to receive the URL")
649	}
650}

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