v0.30.0 eb3068f

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

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