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/application/port"
14 "github.com/bnema/dumber/internal/infrastructure/process"
15 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18)
19
20type browserWindowOpenerFunc func(context.Context, string) error
21
22func (f browserWindowOpenerFunc) OpenExternalURL(ctx context.Context, url string) error {
23 return f(ctx, url)
24}
25
26func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
27 return f(ctx, url)
28}
29
30type browserWindowOpener struct {
31 openExternalURL func(context.Context, string) error
32 openFreshWindow func(context.Context, string) error
33}
34
35func (o browserWindowOpener) OpenExternalURL(ctx context.Context, url string) error {
36 return o.openExternalURL(ctx, url)
37}
38
39func (o browserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {
40 return o.openFreshWindow(ctx, url)
41}
42
43func waitForSocket(t *testing.T, path string) {
44 t.Helper()
45
46 require.Eventually(t, func() bool {
47 _, err := os.Stat(path)
48 return err == nil
49 }, time.Second, 10*time.Millisecond)
50}
51
52func waitForSocketGone(t *testing.T, path string) {
53 t.Helper()
54
55 require.Eventually(t, func() bool {
56 _, err := os.Stat(path)
57 return os.IsNotExist(err)
58 }, time.Second, 10*time.Millisecond)
59}
60
61func shortTempDir(t *testing.T) string {
62 t.Helper()
63
64 dir, err := os.MkdirTemp("", "dbr-")
65 require.NoError(t, err)
66 t.Cleanup(func() {
67 _ = os.RemoveAll(dir)
68 })
69
70 return dir
71}
72
73func TestValidateBrowserLaunchSocketDirOwnedAcceptsCurrentUser(t *testing.T) {
74 ipc := testIPC(shortTempDir(t))
75 require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
76
77 require.NoError(t, validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid())))
78}
79
80func TestValidateBrowserLaunchSocketDirOwnedRejectsUnexpectedOwner(t *testing.T) {
81 ipc := testIPC(shortTempDir(t))
82 require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
83
84 unexpectedUID := uint32(os.Geteuid() + 1)
85 if unexpectedUID == uint32(os.Geteuid()) {
86 unexpectedUID++
87 }
88
89 err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, unexpectedUID)
90 require.Error(t, err)
91 require.Contains(t, err.Error(), "browser launch dir owner")
92}
93
94func TestValidateBrowserLaunchSocketDirOwnedRejectsWritableUserParent(t *testing.T) {
95 root := shortTempDir(t)
96 ipc := runtimeprofile.IPCPaths{
97 RuntimeDir: filepath.Join(root, "dumber", "dev-abc123", "cef"),
98 BrowserLaunchSocket: filepath.Join(root, "dumber", "dev-abc123", "cef", browserLaunchSocketName),
99 }
100 require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
101 require.NoError(t, os.Chmod(filepath.Join(root, "dumber"), 0o777))
102 t.Cleanup(func() {
103 _ = os.Chmod(filepath.Join(root, "dumber"), 0o700)
104 })
105
106 err := validateBrowserLaunchSocketDirOwned(ipc.BrowserLaunchSocket, uint32(os.Geteuid()))
107 require.Error(t, err)
108 require.Contains(t, err.Error(), "browser launch dir permissions")
109}
110
111func testIPC(root string) runtimeprofile.IPCPaths {
112 runtimeDir := filepath.Join(root, "runtime")
113 return runtimeprofile.IPCPaths{
114 RuntimeDir: runtimeDir,
115 BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
116 }
117}
118
119func testNamespacedIPC(root, engine string) runtimeprofile.IPCPaths {
120 runtimeDir := filepath.Join(root, "runtime", engine)
121 return runtimeprofile.IPCPaths{
122 RuntimeDir: runtimeDir,
123 BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
124 }
125}
126
127func overrideBrowserLaunchRequestID(t *testing.T, id string) func() {
128 t.Helper()
129 previous := newBrowserLaunchRequestID
130 newBrowserLaunchRequestID = func() string { return id }
131 return func() { newBrowserLaunchRequestID = previous }
132}
133
134func TestBrowserLaunchRelay_DeliverOpenExternalURL_MissingListenerReturnsFalseNil(t *testing.T) {
135 relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
136
137 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com")
138
139 require.NoError(t, err)
140 assert.False(t, delivered)
141}
142
143func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
144 ipc := testNamespacedIPC(shortTempDir(t), "cef")
145 listenerRelay := NewBrowserLaunchRelay(ipc)
146 clientRelay := NewBrowserLaunchRelay(ipc)
147
148 received := make(chan string, 1)
149 ctx := t.Context()
150
151 closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
152 received <- url
153 return nil
154 }))
155 require.NoError(t, err)
156 defer closer.Close()
157
158 waitForSocket(t, ipc.BrowserLaunchSocket)
159
160 delivered, err := clientRelay.DeliverOpenExternalURL(context.Background(), "https://example.com/same-namespace")
161 require.NoError(t, err)
162 require.True(t, delivered)
163 require.Equal(t, "https://example.com/same-namespace", <-received)
164}
165
166func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) {
167 root := t.TempDir()
168 cefIPC := testNamespacedIPC(root, "cef")
169 wkIPC := testNamespacedIPC(root, "webkit")
170 require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
171}
172
173func TestBrowserLaunchRelay_DeliverOpenExternalURL_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
174 ipc := testIPC(shortTempDir(t))
175 relay := NewBrowserLaunchRelay(ipc)
176 restore := overrideBrowserLaunchRequestID(t, "request-test-1")
177 defer restore()
178
179 receivedRequest := make(chan browserLaunchRequest, 1)
180 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
181 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
182 require.NoError(t, err)
183 defer func() {
184 _ = listener.Close()
185 _ = os.Remove(ipc.BrowserLaunchSocket)
186 }()
187
188 go func() {
189 conn, acceptErr := listener.AcceptUnix()
190 if acceptErr != nil {
191 return
192 }
193 defer conn.Close()
194 var request browserLaunchRequest
195 if decodeErr := json.NewDecoder(conn).Decode(&request); decodeErr != nil {
196 return
197 }
198 receivedRequest <- request
199 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})
200 }()
201
202 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id")
203
204 require.NoError(t, err)
205 require.True(t, delivered)
206 request := <-receivedRequest
207 require.Equal(t, "request-test-1", request.RequestID)
208 require.Equal(t, "https://example.com/request-id", request.URL)
209}
210
211func TestBrowserLaunchRelay_DeliverOpenExternalURL_RejectsMismatchedResponseRequestID(t *testing.T) {
212 ipc := testIPC(shortTempDir(t))
213 relay := NewBrowserLaunchRelay(ipc)
214 restore := overrideBrowserLaunchRequestID(t, "request-test-2")
215 defer restore()
216
217 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
218 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
219 require.NoError(t, err)
220 defer func() {
221 _ = listener.Close()
222 _ = os.Remove(ipc.BrowserLaunchSocket)
223 }()
224
225 go func() {
226 conn, acceptErr := listener.AcceptUnix()
227 if acceptErr != nil {
228 return
229 }
230 defer conn.Close()
231 var request browserLaunchRequest
232 if decodeErr := json.NewDecoder(conn).Decode(&request); decodeErr != nil {
233 return
234 }
235 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})
236 }()
237
238 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id-mismatch")
239
240 require.Error(t, err)
241 require.True(t, delivered)
242 require.Contains(t, err.Error(), "mismatched browser launch relay response")
243}
244
245func TestBrowserLaunchRelay_DeliverOpenExternalURL_RoundTrip(t *testing.T) {
246 ipc := testIPC(shortTempDir(t))
247 relay := NewBrowserLaunchRelay(ipc)
248
249 received := make(chan string, 1)
250 ctx := t.Context()
251
252 closer, err := relay.Listen(ctx, browserWindowOpener{
253 openExternalURL: func(_ context.Context, url string) error {
254 received <- url
255 return nil
256 },
257 openFreshWindow: func(context.Context, string) error {
258 t.Fatal("external URL relay request must preserve configured placement")
259 return nil
260 },
261 })
262 require.NoError(t, err)
263 defer closer.Close()
264
265 waitForSocket(t, ipc.BrowserLaunchSocket)
266
267 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/new")
268
269 require.NoError(t, err)
270 assert.True(t, delivered)
271
272 select {
273 case got := <-received:
274 assert.Equal(t, "https://example.com/new", got)
275 case <-time.After(time.Second):
276 t.Fatal("expected opener to receive the URL")
277 }
278}
279
280func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
281 ipc := testIPC(shortTempDir(t))
282 relay := NewBrowserLaunchRelay(ipc)
283
284 freshWindows := make(chan string, 1)
285 ctx := t.Context()
286 closer, err := relay.Listen(ctx, browserWindowOpener{
287 openExternalURL: func(context.Context, string) error {
288 t.Fatal("external-link placement must not handle standalone omnibox navigation")
289 return nil
290 },
291 openFreshWindow: func(_ context.Context, url string) error {
292 freshWindows <- url
293 return nil
294 },
295 })
296 require.NoError(t, err)
297 defer closer.Close()
298
299 waitForSocket(t, ipc.BrowserLaunchSocket)
300 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/omnibox")
301
302 require.NoError(t, err)
303 require.True(t, delivered)
304 select {
305 case got := <-freshWindows:
306 assert.Equal(t, "https://example.com/omnibox", got)
307 case <-time.After(time.Second):
308 t.Fatal("expected fresh-window opener to receive the URL")
309 }
310}
311
312func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) {
313 relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{})
314
315 _, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
316 require.Error(t, err)
317 require.Contains(t, err.Error(), "browser launch socket path")
318}
319
320func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {
321 ipc := testIPC(shortTempDir(t))
322 relay := NewBrowserLaunchRelay(ipc)
323
324 firstReceived := make(chan string, 1)
325 ctx := t.Context()
326
327 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
328 firstReceived <- url
329 return nil
330 }))
331 require.NoError(t, err)
332 defer closer.Close()
333
334 waitForSocket(t, ipc.BrowserLaunchSocket)
335
336 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
337 require.Error(t, err)
338
339 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/live")
340 require.NoError(t, err)
341 require.True(t, delivered)
342
343 select {
344 case got := <-firstReceived:
345 assert.Equal(t, "https://example.com/live", got)
346 case <-time.After(time.Second):
347 t.Fatal("expected the original listener to remain active")
348 }
349}
350
351func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
352 ipc := testIPC(shortTempDir(t))
353 relay := NewBrowserLaunchRelay(ipc)
354
355 require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
356 require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600))
357
358 received := make(chan string, 1)
359 ctx := t.Context()
360
361 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
362 received <- url
363 return nil
364 }))
365 require.NoError(t, err)
366 defer closer.Close()
367
368 waitForSocket(t, ipc.BrowserLaunchSocket)
369
370 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/stale")
371 require.NoError(t, err)
372 require.True(t, delivered)
373
374 select {
375 case got := <-received:
376 assert.Equal(t, "https://example.com/stale", got)
377 case <-time.After(time.Second):
378 t.Fatal("expected stale socket path to be rebound")
379 }
380}
381
382func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
383 ipc := testIPC(shortTempDir(t))
384 relay := NewBrowserLaunchRelay(ipc)
385
386 received := make(chan string, 1)
387 ctx := t.Context()
388
389 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
390 received <- url
391 return nil
392 }))
393 require.NoError(t, err)
394 defer closer.Close()
395
396 waitForSocket(t, ipc.BrowserLaunchSocket)
397
398 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
399 require.NoError(t, err)
400
401 _, err = conn.Write([]byte("not-json"))
402 require.NoError(t, err)
403 require.NoError(t, conn.Close())
404
405 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/still-works")
406
407 require.NoError(t, err)
408 assert.True(t, delivered)
409
410 select {
411 case got := <-received:
412 assert.Equal(t, "https://example.com/still-works", got)
413 case <-time.After(time.Second):
414 t.Fatal("expected opener to receive the URL after malformed payload")
415 }
416}
417
418func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
419 ipc := testIPC(shortTempDir(t))
420 relay := NewBrowserLaunchRelay(ipc)
421
422 ctx := t.Context()
423
424 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
425 require.NoError(t, err)
426
427 waitForSocket(t, ipc.BrowserLaunchSocket)
428
429 require.NoError(t, closer.Close())
430 waitForSocketGone(t, ipc.BrowserLaunchSocket)
431}
432
433func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
434 ipc := testIPC(shortTempDir(t))
435 relay := NewBrowserLaunchRelay(ipc)
436
437 ctx, cancel := context.WithCancel(context.Background())
438 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
439 require.NoError(t, err)
440
441 waitForSocket(t, ipc.BrowserLaunchSocket)
442
443 cancel()
444 waitForSocketGone(t, ipc.BrowserLaunchSocket)
445 require.NoError(t, closer.Close())
446
447 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/after-cancel")
448 require.NoError(t, err)
449 assert.False(t, delivered)
450}
451
452func TestBrowserLaunchRelay_DeliverOpenExternalURL_RespectsResponseReadTimeout(t *testing.T) {
453 ipc := testIPC(shortTempDir(t))
454 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
455
456 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
457 require.NoError(t, err)
458 defer func() {
459 _ = listener.Close()
460 _ = os.Remove(ipc.BrowserLaunchSocket)
461 }()
462
463 ready := make(chan struct{})
464 go func() {
465 close(ready)
466 conn, acceptErr := listener.AcceptUnix()
467 if acceptErr != nil {
468 return
469 }
470 defer conn.Close()
471 <-time.After(time.Second)
472 }()
473 <-ready
474
475 relay := NewBrowserLaunchRelay(ipc)
476 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
477 defer cancel()
478
479 delivered, err := relay.DeliverOpenExternalURL(ctx, "https://example.com/slow")
480 require.Error(t, err)
481 require.ErrorIs(t, err, context.DeadlineExceeded)
482 assert.False(t, delivered)
483}
484
485func TestBrowserLaunchRelay_DeliverOpenExternalURL_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
486 ipc := testIPC(shortTempDir(t))
487 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
488
489 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
490 require.NoError(t, err)
491 defer func() {
492 _ = listener.Close()
493 _ = os.Remove(ipc.BrowserLaunchSocket)
494 }()
495
496 ready := make(chan struct{})
497 go func() {
498 close(ready)
499 conn, acceptErr := listener.AcceptUnix()
500 if acceptErr != nil {
501 return
502 }
503 defer conn.Close()
504 <-time.After(time.Second)
505 }()
506 <-ready
507
508 relay := NewBrowserLaunchRelay(ipc)
509
510 started := make(chan struct{})
511
512 type deliverResult struct {
513 delivered bool
514 err error
515 }
516 result := make(chan deliverResult, 1)
517 go func() {
518 close(started)
519 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/slow")
520 result <- deliverResult{delivered: delivered, err: err}
521 }()
522 <-started
523
524 select {
525 case got := <-result:
526 assert.True(t, got.delivered)
527 require.Error(t, got.err)
528 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)
529 case <-time.After(browserLaunchIOTimeout * 3):
530 t.Fatal("DeliverOpenExternalURL blocked without a caller deadline")
531 }
532}
533
534func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
535 ipc := testIPC(shortTempDir(t))
536 relay := NewBrowserLaunchRelay(ipc)
537
538 started := make(chan struct{})
539 release := make(chan struct{})
540 ctx := t.Context()
541
542 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
543 close(started)
544 <-release
545 return nil
546 }))
547 require.NoError(t, err)
548 defer closer.Close()
549
550 waitForSocket(t, ipc.BrowserLaunchSocket)
551
552 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
553 require.NoError(t, err)
554 defer conn.Close()
555 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
556 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "raw-request-1", URL: "https://example.com/slow-but-healthy"}))
557
558 var response browserLaunchResponse
559 require.NoError(t, json.NewDecoder(conn).Decode(&response))
560 assert.Empty(t, response.Error)
561 assert.True(t, response.Accepted)
562 assert.Equal(t, "raw-request-1", response.RequestID)
563
564 select {
565 case <-started:
566 case <-time.After(time.Second):
567 t.Fatal("expected opener to be invoked after acknowledgement")
568 }
569 close(release)
570}
571
572func TestBrowserLaunchRelay_AcknowledgesAcceptedEvenWhenOpenerReturnsLateError(t *testing.T) {
573 ipc := testIPC(shortTempDir(t))
574 relay := NewBrowserLaunchRelay(ipc)
575
576 started := make(chan struct{})
577 ctx := t.Context()
578
579 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
580 close(started)
581 return errors.New("late open failure")
582 }))
583 require.NoError(t, err)
584 defer closer.Close()
585
586 waitForSocket(t, ipc.BrowserLaunchSocket)
587
588 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
589 require.NoError(t, err)
590 defer conn.Close()
591 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
592 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "raw-request-2", URL: "https://example.com/fails-later"}))
593
594 var response browserLaunchResponse
595 require.NoError(t, json.NewDecoder(conn).Decode(&response))
596 assert.Empty(t, response.Error)
597 assert.True(t, response.Accepted)
598 assert.Equal(t, "raw-request-2", response.RequestID)
599
600 select {
601 case <-started:
602 case <-time.After(time.Second):
603 t.Fatal("expected opener to be invoked after accepted acknowledgement")
604 }
605}
606
607func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
608 ipc := testIPC(shortTempDir(t))
609 relay := NewBrowserLaunchRelay(ipc)
610
611 received := make(chan string, 1)
612 ctx := t.Context()
613
614 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
615 received <- url
616 return nil
617 }))
618 require.NoError(t, err)
619 defer closer.Close()
620
621 waitForSocket(t, ipc.BrowserLaunchSocket)
622
623 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
624 require.NoError(t, err)
625 defer conn.Close()
626 time.Sleep(20 * time.Millisecond)
627
628 type deliverResult struct {
629 delivered bool
630 err error
631 }
632 result := make(chan deliverResult, 1)
633 go func() {
634 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/recovered")
635 result <- deliverResult{delivered: delivered, err: err}
636 }()
637
638 select {
639 case got := <-result:
640 require.NoError(t, got.err)
641 require.True(t, got.delivered)
642 case <-time.After(300 * time.Millisecond):
643 t.Fatal("silent client stalled the relay listener")
644 }
645
646 select {
647 case got := <-received:
648 assert.Equal(t, "https://example.com/recovered", got)
649 case <-time.After(time.Second):
650 t.Fatal("expected opener to receive the URL")
651 }
652}
653
654type blockingRelayOpener struct {
655 release chan struct{}
656 received chan string
657}
658
659func (o *blockingRelayOpener) OpenExternalURL(_ context.Context, url string) error {
660 o.received <- url
661 <-o.release
662 return nil
663}
664
665func (o *blockingRelayOpener) OpenFreshWindow(_ context.Context, url string) error {
666 return o.OpenExternalURL(context.Background(), url)
667}
668
669func startLeaseTestListener(t *testing.T, opener port.BrowserWindowOpener) *browserLaunchRelay {
670 t.Helper()
671 ipc := testIPC(shortTempDir(t))
672 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
673 relay := NewBrowserLaunchRelay(ipc).(*browserLaunchRelay)
674 ctx, cancel := context.WithCancel(context.Background())
675 t.Cleanup(cancel)
676 closer, err := relay.Listen(ctx, opener)
677 require.NoError(t, err)
678 t.Cleanup(func() { _ = closer.Close() })
679 return relay
680}
681
682func waitForLeaseCount(t *testing.T, gate *process.AdmissionGate, want int) {
683 t.Helper()
684 deadline := time.Now().Add(3 * time.Second)
685 for time.Now().Before(deadline) {
686 if gate.Active() == want {
687 return
688 }
689 time.Sleep(5 * time.Millisecond)
690 }
691 t.Fatalf("lease count = %d, want %d", gate.Active(), want)
692}
693
694func TestBrowserLaunchRelay_HoldsLeaseThroughDispatch(t *testing.T) {
695 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 2)}
696 relay := startLeaseTestListener(t, opener)
697 gate := relay.AdmissionGate()
698
699 delivered := make(chan error, 1)
700 go func() {
701 _, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/lease")
702 delivered <- err
703 }()
704 select {
705 case url := <-opener.received:
706 require.Equal(t, "https://example.com/lease", url)
707 case <-time.After(3 * time.Second):
708 t.Fatal("opener never received the request")
709 }
710 // Acknowledged and dispatched: the lease is held until dispatch settles.
711 waitForLeaseCount(t, gate, 1)
712 select {
713 case err := <-delivered:
714 require.NoError(t, err)
715 case <-time.After(3 * time.Second):
716 t.Fatal("client never received acknowledgement")
717 }
718 close(opener.release)
719 waitForLeaseCount(t, gate, 0)
720}
721
722func TestBrowserLaunchRelay_SimultaneousOpensHoldLeases(t *testing.T) {
723 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 4)}
724 relay := startLeaseTestListener(t, opener)
725 gate := relay.AdmissionGate()
726
727 for range 2 {
728 go func() {
729 _, _ = relay.DeliverOpenExternalURL(context.Background(), "https://example.com/parallel")
730 }()
731 }
732 for range 2 {
733 select {
734 case <-opener.received:
735 case <-time.After(3 * time.Second):
736 t.Fatal("opener never received a parallel request")
737 }
738 }
739 waitForLeaseCount(t, gate, 2)
740 close(opener.release)
741 waitForLeaseCount(t, gate, 0)
742}
743
744func TestBrowserLaunchRelay_ClosedAdmissionSendsErrorResponse(t *testing.T) {
745 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
746 relay := startLeaseTestListener(t, opener)
747 gate := relay.AdmissionGate()
748 close(opener.release)
749
750 gate.Close()
751 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/late")
752 require.Error(t, err, "losing request must receive the error response")
753 require.Contains(t, err.Error(), "shutting down")
754 require.Equal(t, 0, gate.Active(), "refused requests hold no lease")
755 select {
756 case url := <-opener.received:
757 t.Fatalf("refused request reached the opener: %s", url)
758 case <-time.After(300 * time.Millisecond):
759 }
760 _ = delivered
761}
762
763type closeTrackingOpener struct {
764 *blockingRelayOpener
765 closed chan struct{}
766}
767
768func (o *closeTrackingOpener) CloseAllWindows(_ context.Context) error {
769 close(o.closed)
770 return nil
771}
772
773func sendRelayAction(t *testing.T, socketPath, action string) browserLaunchResponse {
774 t.Helper()
775 conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"})
776 require.NoError(t, err)
777 defer func() { _ = conn.Close() }()
778 require.NoError(t, conn.SetDeadline(time.Now().Add(3*time.Second)))
779 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "diag-1", Action: browserLaunchAction(action)}))
780 var response browserLaunchResponse
781 require.NoError(t, json.NewDecoder(conn).Decode(&response))
782 return response
783}
784
785func TestBrowserLaunchRelay_DiagnosticCloseRejectedWithoutOptIn(t *testing.T) {
786 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
787 close(opener.release)
788 relay := startLeaseTestListener(t, opener)
789 response := sendRelayAction(t, relay.ipc.BrowserLaunchSocket, "close-all-windows")
790 require.False(t, response.Accepted, "rejected close must not acknowledge")
791 require.Contains(t, response.Error, "diagnostic close not enabled")
792}
793
794func TestBrowserLaunchRelay_DiagnosticCloseDispatchedWithOptIn(t *testing.T) {
795 t.Setenv(diagnosticWindowCloseEnvVar, "1")
796 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
797 close(opener.release)
798 tracker := &closeTrackingOpener{blockingRelayOpener: opener, closed: make(chan struct{})}
799 relay := startLeaseTestListener(t, tracker)
800 response := sendRelayAction(t, relay.ipc.BrowserLaunchSocket, "close-all-windows")
801 require.True(t, response.Accepted)
802 select {
803 case <-tracker.closed:
804 case <-time.After(3 * time.Second):
805 t.Fatal("diagnostic close never reached the opener")
806 }
807}