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_CloseDoesNotRemoveSuccessorSocket(t *testing.T) {
434 ipc := testIPC(shortTempDir(t))
435 relay := NewBrowserLaunchRelay(ipc)
436 closer, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
437 require.NoError(t, err)
438 waitForSocket(t, ipc.BrowserLaunchSocket)
439
440 require.NoError(t, os.Remove(ipc.BrowserLaunchSocket))
441 successor, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
442 require.NoError(t, err)
443 defer successor.Close()
444 require.NoError(t, closer.Close())
445 _, err = os.Lstat(ipc.BrowserLaunchSocket)
446 require.NoError(t, err, "closing predecessor must preserve successor pathname")
447}
448
449func TestBrowserLaunchRelay_ClientCloseBeforeAckDispatchesOnceAndDeduplicates(t *testing.T) {
450 ipc := testIPC(shortTempDir(t))
451 relay := NewBrowserLaunchRelay(ipc)
452 received := make(chan string, 2)
453 closer, err := relay.Listen(t.Context(), browserWindowOpenerFunc(func(_ context.Context, url string) error { received <- url; return nil }))
454 require.NoError(t, err)
455 defer closer.Close()
456 waitForSocket(t, ipc.BrowserLaunchSocket)
457
458 send := func() {
459 conn, dialErr := net.Dial("unix", ipc.BrowserLaunchSocket)
460 require.NoError(t, dialErr)
461 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "retained-1", URL: "https://example.com/retained"}))
462 require.NoError(t, conn.Close())
463 }
464 send()
465 require.Equal(t, "https://example.com/retained", <-received)
466 send()
467 select {
468 case got := <-received:
469 t.Fatalf("duplicate request dispatched: %s", got)
470 case <-time.After(100 * time.Millisecond):
471 }
472}
473
474func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
475 ipc := testIPC(shortTempDir(t))
476 relay := NewBrowserLaunchRelay(ipc)
477
478 ctx, cancel := context.WithCancel(context.Background())
479 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
480 require.NoError(t, err)
481
482 waitForSocket(t, ipc.BrowserLaunchSocket)
483
484 cancel()
485 waitForSocketGone(t, ipc.BrowserLaunchSocket)
486 require.NoError(t, closer.Close())
487
488 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/after-cancel")
489 require.NoError(t, err)
490 assert.False(t, delivered)
491}
492
493func TestBrowserLaunchRelay_DeliverOpenExternalURL_RespectsResponseReadTimeout(t *testing.T) {
494 ipc := testIPC(shortTempDir(t))
495 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
496
497 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
498 require.NoError(t, err)
499 defer func() {
500 _ = listener.Close()
501 _ = os.Remove(ipc.BrowserLaunchSocket)
502 }()
503
504 ready := make(chan struct{})
505 go func() {
506 close(ready)
507 conn, acceptErr := listener.AcceptUnix()
508 if acceptErr != nil {
509 return
510 }
511 defer conn.Close()
512 <-time.After(time.Second)
513 }()
514 <-ready
515
516 relay := NewBrowserLaunchRelay(ipc)
517 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
518 defer cancel()
519
520 delivered, err := relay.DeliverOpenExternalURL(ctx, "https://example.com/slow")
521 require.Error(t, err)
522 require.ErrorIs(t, err, context.DeadlineExceeded)
523 assert.False(t, delivered)
524}
525
526func TestBrowserLaunchRelay_DeliverOpenExternalURL_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
527 ipc := testIPC(shortTempDir(t))
528 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
529
530 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
531 require.NoError(t, err)
532 defer func() {
533 _ = listener.Close()
534 _ = os.Remove(ipc.BrowserLaunchSocket)
535 }()
536
537 ready := make(chan struct{})
538 go func() {
539 close(ready)
540 conn, acceptErr := listener.AcceptUnix()
541 if acceptErr != nil {
542 return
543 }
544 defer conn.Close()
545 <-time.After(time.Second)
546 }()
547 <-ready
548
549 relay := NewBrowserLaunchRelay(ipc)
550
551 started := make(chan struct{})
552
553 type deliverResult struct {
554 delivered bool
555 err error
556 }
557 result := make(chan deliverResult, 1)
558 go func() {
559 close(started)
560 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/slow")
561 result <- deliverResult{delivered: delivered, err: err}
562 }()
563 <-started
564
565 select {
566 case got := <-result:
567 assert.True(t, got.delivered)
568 require.Error(t, got.err)
569 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)
570 case <-time.After(browserLaunchIOTimeout * 3):
571 t.Fatal("DeliverOpenExternalURL blocked without a caller deadline")
572 }
573}
574
575func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
576 ipc := testIPC(shortTempDir(t))
577 relay := NewBrowserLaunchRelay(ipc)
578
579 started := make(chan struct{})
580 release := make(chan struct{})
581 ctx := t.Context()
582
583 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
584 close(started)
585 <-release
586 return nil
587 }))
588 require.NoError(t, err)
589 defer closer.Close()
590
591 waitForSocket(t, ipc.BrowserLaunchSocket)
592
593 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
594 require.NoError(t, err)
595 defer conn.Close()
596 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
597 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "raw-request-1", URL: "https://example.com/slow-but-healthy"}))
598
599 var response browserLaunchResponse
600 require.NoError(t, json.NewDecoder(conn).Decode(&response))
601 assert.Empty(t, response.Error)
602 assert.True(t, response.Accepted)
603 assert.Equal(t, "raw-request-1", response.RequestID)
604
605 select {
606 case <-started:
607 case <-time.After(time.Second):
608 t.Fatal("expected opener to be invoked after acknowledgement")
609 }
610 close(release)
611}
612
613func TestBrowserLaunchRelay_AcknowledgesAcceptedEvenWhenOpenerReturnsLateError(t *testing.T) {
614 ipc := testIPC(shortTempDir(t))
615 relay := NewBrowserLaunchRelay(ipc)
616
617 started := make(chan struct{})
618 ctx := t.Context()
619
620 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
621 close(started)
622 return errors.New("late open failure")
623 }))
624 require.NoError(t, err)
625 defer closer.Close()
626
627 waitForSocket(t, ipc.BrowserLaunchSocket)
628
629 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
630 require.NoError(t, err)
631 defer conn.Close()
632 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
633 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "raw-request-2", URL: "https://example.com/fails-later"}))
634
635 var response browserLaunchResponse
636 require.NoError(t, json.NewDecoder(conn).Decode(&response))
637 assert.Empty(t, response.Error)
638 assert.True(t, response.Accepted)
639 assert.Equal(t, "raw-request-2", response.RequestID)
640
641 select {
642 case <-started:
643 case <-time.After(time.Second):
644 t.Fatal("expected opener to be invoked after accepted acknowledgement")
645 }
646}
647
648func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
649 ipc := testIPC(shortTempDir(t))
650 relay := NewBrowserLaunchRelay(ipc)
651
652 received := make(chan string, 1)
653 ctx := t.Context()
654
655 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
656 received <- url
657 return nil
658 }))
659 require.NoError(t, err)
660 defer closer.Close()
661
662 waitForSocket(t, ipc.BrowserLaunchSocket)
663
664 conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
665 require.NoError(t, err)
666 defer conn.Close()
667 time.Sleep(20 * time.Millisecond)
668
669 type deliverResult struct {
670 delivered bool
671 err error
672 }
673 result := make(chan deliverResult, 1)
674 go func() {
675 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/recovered")
676 result <- deliverResult{delivered: delivered, err: err}
677 }()
678
679 select {
680 case got := <-result:
681 require.NoError(t, got.err)
682 require.True(t, got.delivered)
683 case <-time.After(300 * time.Millisecond):
684 t.Fatal("silent client stalled the relay listener")
685 }
686
687 select {
688 case got := <-received:
689 assert.Equal(t, "https://example.com/recovered", got)
690 case <-time.After(time.Second):
691 t.Fatal("expected opener to receive the URL")
692 }
693}
694
695type blockingRelayOpener struct {
696 release chan struct{}
697 received chan string
698}
699
700func (o *blockingRelayOpener) OpenExternalURL(_ context.Context, url string) error {
701 o.received <- url
702 <-o.release
703 return nil
704}
705
706func (o *blockingRelayOpener) OpenFreshWindow(_ context.Context, url string) error {
707 return o.OpenExternalURL(context.Background(), url)
708}
709
710func startLeaseTestListener(t *testing.T, opener port.BrowserWindowOpener) *browserLaunchRelay {
711 t.Helper()
712 ipc := testIPC(shortTempDir(t))
713 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
714 relay := NewBrowserLaunchRelay(ipc).(*browserLaunchRelay)
715 ctx, cancel := context.WithCancel(context.Background())
716 t.Cleanup(cancel)
717 closer, err := relay.Listen(ctx, opener)
718 require.NoError(t, err)
719 t.Cleanup(func() { _ = closer.Close() })
720 return relay
721}
722
723func waitForLeaseCount(t *testing.T, gate *process.AdmissionGate, want int) {
724 t.Helper()
725 deadline := time.Now().Add(3 * time.Second)
726 for time.Now().Before(deadline) {
727 if gate.Active() == want {
728 return
729 }
730 time.Sleep(5 * time.Millisecond)
731 }
732 t.Fatalf("lease count = %d, want %d", gate.Active(), want)
733}
734
735func TestBrowserLaunchRelay_HoldsLeaseThroughDispatch(t *testing.T) {
736 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 2)}
737 relay := startLeaseTestListener(t, opener)
738 gate := relay.AdmissionGate()
739
740 delivered := make(chan error, 1)
741 go func() {
742 _, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/lease")
743 delivered <- err
744 }()
745 select {
746 case url := <-opener.received:
747 require.Equal(t, "https://example.com/lease", url)
748 case <-time.After(3 * time.Second):
749 t.Fatal("opener never received the request")
750 }
751 // Acknowledged and dispatched: the lease is held until dispatch settles.
752 waitForLeaseCount(t, gate, 1)
753 select {
754 case err := <-delivered:
755 require.NoError(t, err)
756 case <-time.After(3 * time.Second):
757 t.Fatal("client never received acknowledgement")
758 }
759 close(opener.release)
760 waitForLeaseCount(t, gate, 0)
761}
762
763func TestBrowserLaunchRelay_SimultaneousOpensHoldLeases(t *testing.T) {
764 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 4)}
765 relay := startLeaseTestListener(t, opener)
766 gate := relay.AdmissionGate()
767
768 for range 2 {
769 go func() {
770 _, _ = relay.DeliverOpenExternalURL(context.Background(), "https://example.com/parallel")
771 }()
772 }
773 for range 2 {
774 select {
775 case <-opener.received:
776 case <-time.After(3 * time.Second):
777 t.Fatal("opener never received a parallel request")
778 }
779 }
780 waitForLeaseCount(t, gate, 2)
781 close(opener.release)
782 waitForLeaseCount(t, gate, 0)
783}
784
785func TestBrowserLaunchRelay_ClosedAdmissionSendsErrorResponse(t *testing.T) {
786 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
787 relay := startLeaseTestListener(t, opener)
788 gate := relay.AdmissionGate()
789 close(opener.release)
790
791 gate.Close()
792 delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/late")
793 require.Error(t, err, "losing request must receive the error response")
794 require.Contains(t, err.Error(), "shutting down")
795 require.Equal(t, 0, gate.Active(), "refused requests hold no lease")
796 select {
797 case url := <-opener.received:
798 t.Fatalf("refused request reached the opener: %s", url)
799 case <-time.After(300 * time.Millisecond):
800 }
801 _ = delivered
802}
803
804type closeTrackingOpener struct {
805 *blockingRelayOpener
806 closed chan struct{}
807}
808
809func (o *closeTrackingOpener) CloseAllWindows(_ context.Context) error {
810 close(o.closed)
811 return nil
812}
813
814func sendRelayAction(t *testing.T, socketPath, action string) browserLaunchResponse {
815 t.Helper()
816 conn, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"})
817 require.NoError(t, err)
818 defer func() { _ = conn.Close() }()
819 require.NoError(t, conn.SetDeadline(time.Now().Add(3*time.Second)))
820 require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{RequestID: "diag-1", Action: browserLaunchAction(action)}))
821 var response browserLaunchResponse
822 require.NoError(t, json.NewDecoder(conn).Decode(&response))
823 return response
824}
825
826func TestBrowserLaunchRelay_DiagnosticCloseRejectedWithoutOptIn(t *testing.T) {
827 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
828 close(opener.release)
829 relay := startLeaseTestListener(t, opener)
830 response := sendRelayAction(t, relay.ipc.BrowserLaunchSocket, "close-all-windows")
831 require.False(t, response.Accepted, "rejected close must not acknowledge")
832 require.Contains(t, response.Error, "diagnostic close not enabled")
833}
834
835func TestBrowserLaunchRelay_DiagnosticCloseDispatchedWithOptIn(t *testing.T) {
836 t.Setenv(diagnosticWindowCloseEnvVar, "1")
837 opener := &blockingRelayOpener{release: make(chan struct{}), received: make(chan string, 1)}
838 close(opener.release)
839 tracker := &closeTrackingOpener{blockingRelayOpener: opener, closed: make(chan struct{})}
840 relay := startLeaseTestListener(t, tracker)
841 response := sendRelayAction(t, relay.ipc.BrowserLaunchSocket, "close-all-windows")
842 require.True(t, response.Accepted)
843 select {
844 case <-tracker.closed:
845 case <-time.After(3 * time.Second):
846 t.Fatal("diagnostic close never reached the opener")
847 }
848}