feat: relay browser launches before startup
- commit
- 2c39d197e73beabea42eb2d0455d8598340ae1fd
- author
- brice · · 2026-04-14 15:12 UTC
- parent
- 1095483
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +171 -3
M cmd/dumber/main.go +24 -3
| @@ -53,6 +53,9 @@ var initialURL string | |||
| 53 | 53 | // restoreSessionID holds the session ID to restore on startup. | |
| 54 | 54 | var restoreSessionID string | |
| 55 | 55 | ||
| 56 | + | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | |
| 57 | + | var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New()) | |
| 58 | + | ||
| 56 | 59 | type launchMode string | |
| 57 | 60 | ||
| 58 | 61 | const ( | |
| @@ -86,6 +89,14 @@ func launchModeFromArgs(args []string) (launchMode, string) { | |||
| 86 | 89 | return launchModeCLI, "" | |
| 87 | 90 | } | |
| 88 | 91 | ||
| 92 | + | func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.BrowserLaunchRelay, browseURL string) (bool, error) { | |
| 93 | + | if relay == nil || browseURL == "" { | |
| 94 | + | return false, nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | return relay.DeliverOpenFreshWindow(ctx, browseURL) | |
| 98 | + | } | |
| 99 | + | ||
| 89 | 100 | func main() { | |
| 90 | 101 | enableCrashForensics() | |
| 91 | 102 | ||
| @@ -103,6 +114,13 @@ func main() { | |||
| 103 | 114 | mode, browseURL := launchModeFromArgs(os.Args) | |
| 104 | 115 | // Run GUI mode for browse command | |
| 105 | 116 | if mode == launchModeBrowse { | |
| 117 | + | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | |
| 118 | + | fmt.Fprintf(os.Stderr, "failed to forward browse URL %q to a running instance: %v\n", browseURL, err) | |
| 119 | + | os.Exit(1) | |
| 120 | + | } else if forwarded { | |
| 121 | + | os.Exit(0) | |
| 122 | + | } | |
| 123 | + | ||
| 106 | 124 | initialURL = browseURL | |
| 107 | 125 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 108 | 126 | os.Args = os.Args[:1] | |
| @@ -695,6 +713,7 @@ func buildUIDependencies( | |||
| 695 | 713 | } | |
| 696 | 714 | ||
| 697 | 715 | focusProvider := textinput.NewFocusProvider() | |
| 716 | + | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | |
| 698 | 717 | ||
| 699 | 718 | uiDeps := &ui.Dependencies{ | |
| 700 | 719 | Ctx: ctx, | |
| @@ -759,9 +778,11 @@ func buildUIDependencies( | |||
| 759 | 778 | return config.GetManager().Watch() | |
| 760 | 779 | }, | |
| 761 | 780 | LaunchExternalURL: desktop.LaunchExternalURL, | |
| 762 | - | LaunchBrowserURL: desktop.LaunchBrowserURL, | |
| 763 | - | ConfigMigrator: config.NewMigrator(), | |
| 764 | - | HandlerDeps: *handlerDeps, | |
| 781 | + | LaunchBrowserURL: func(uri string) { | |
| 782 | + | browserLauncher.LaunchURL(ctx, uri) | |
| 783 | + | }, | |
| 784 | + | ConfigMigrator: config.NewMigrator(), | |
| 785 | + | HandlerDeps: *handlerDeps, | |
| 765 | 786 | } | |
| 766 | 787 | ||
| 767 | 788 | return uiDeps, nil | |
| @@ -53,6 +53,9 @@ var initialURL string | |||||
| 53 | // restoreSessionID holds the session ID to restore on startup. | 53 | // restoreSessionID holds the session ID to restore on startup. | ||
| 54 | var restoreSessionID string | 54 | var restoreSessionID string | ||
| 55 | 55 | ||||
| 56 | + | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | |||
| 57 | + | var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New()) | |||
| 58 | + | ||||
| 56 | type launchMode string | 59 | type launchMode string | ||
| 57 | 60 | ||||
| 58 | const ( | 61 | const ( | ||
| @@ -86,6 +89,14 @@ func launchModeFromArgs(args []string) (launchMode, string) { | |||||
| 86 | return launchModeCLI, "" | 89 | return launchModeCLI, "" | ||
| 87 | } | 90 | } | ||
| 88 | 91 | ||||
| 92 | + | func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.BrowserLaunchRelay, browseURL string) (bool, error) { | |||
| 93 | + | if relay == nil || browseURL == "" { | |||
| 94 | + | return false, nil | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | return relay.DeliverOpenFreshWindow(ctx, browseURL) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 89 | func main() { | 100 | func main() { | ||
| 90 | enableCrashForensics() | 101 | enableCrashForensics() | ||
| 91 | 102 | ||||
| @@ -103,6 +114,13 @@ func main() { | |||||
| 103 | mode, browseURL := launchModeFromArgs(os.Args) | 114 | mode, browseURL := launchModeFromArgs(os.Args) | ||
| 104 | // Run GUI mode for browse command | 115 | // Run GUI mode for browse command | ||
| 105 | if mode == launchModeBrowse { | 116 | if mode == launchModeBrowse { | ||
| 117 | + | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | |||
| 118 | + | fmt.Fprintf(os.Stderr, "failed to forward browse URL %q to a running instance: %v\n", browseURL, err) | |||
| 119 | + | os.Exit(1) | |||
| 120 | + | } else if forwarded { | |||
| 121 | + | os.Exit(0) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 106 | initialURL = browseURL | 124 | initialURL = browseURL | ||
| 107 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | 125 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | ||
| 108 | os.Args = os.Args[:1] | 126 | os.Args = os.Args[:1] | ||
| @@ -695,6 +713,7 @@ func buildUIDependencies( | |||||
| 695 | } | 713 | } | ||
| 696 | 714 | ||||
| 697 | focusProvider := textinput.NewFocusProvider() | 715 | focusProvider := textinput.NewFocusProvider() | ||
| 716 | + | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | |||
| 698 | 717 | ||||
| 699 | uiDeps := &ui.Dependencies{ | 718 | uiDeps := &ui.Dependencies{ | ||
| 700 | Ctx: ctx, | 719 | Ctx: ctx, | ||
| @@ -759,9 +778,11 @@ func buildUIDependencies( | |||||
| 759 | return config.GetManager().Watch() | 778 | return config.GetManager().Watch() | ||
| 760 | }, | 779 | }, | ||
| 761 | LaunchExternalURL: desktop.LaunchExternalURL, | 780 | LaunchExternalURL: desktop.LaunchExternalURL, | ||
| 762 | - | LaunchBrowserURL: desktop.LaunchBrowserURL, | 781 | + | LaunchBrowserURL: func(uri string) { |
| 763 | - | ConfigMigrator: config.NewMigrator(), | 782 | + | browserLauncher.LaunchURL(ctx, uri) |
| 764 | - | HandlerDeps: *handlerDeps, | 783 | + | }, |
| 784 | + | ConfigMigrator: config.NewMigrator(), | |||
| 785 | + | HandlerDeps: *handlerDeps, | |||
| 765 | } | 786 | } | ||
| 766 | 787 | ||||
| 767 | return uiDeps, nil | 788 | return uiDeps, nil | ||
M cmd/dumber/main_test.go +30 -0
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package main | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 7 | 9 | "github.com/bnema/dumber/internal/bootstrap" | |
| 8 | 10 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 9 | 11 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| @@ -131,3 +133,31 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) { | |||
| 131 | 133 | t.Fatal("expected adwaita detector to remain unavailable") | |
| 132 | 134 | } | |
| 133 | 135 | } | |
| 136 | + | ||
| 137 | + | func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) { | |
| 138 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 139 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil) | |
| 140 | + | ||
| 141 | + | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 142 | + | ||
| 143 | + | if err != nil { | |
| 144 | + | t.Fatalf("expected no error, got %v", err) | |
| 145 | + | } | |
| 146 | + | if !forwarded { | |
| 147 | + | t.Fatal("expected browse URL to be forwarded") | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) { | |
| 152 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 153 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil) | |
| 154 | + | ||
| 155 | + | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 156 | + | ||
| 157 | + | if err != nil { | |
| 158 | + | t.Fatalf("expected no error, got %v", err) | |
| 159 | + | } | |
| 160 | + | if forwarded { | |
| 161 | + | t.Fatal("expected browse URL to remain unforwarded") | |
| 162 | + | } | |
| 163 | + | } | |
| @@ -1,9 +1,11 @@ | |||||
| 1 | package main | 1 | package main | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 7 | "github.com/bnema/dumber/internal/bootstrap" | 9 | "github.com/bnema/dumber/internal/bootstrap" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | 10 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/config" | 11 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| @@ -131,3 +133,31 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) { | |||||
| 131 | t.Fatal("expected adwaita detector to remain unavailable") | 133 | t.Fatal("expected adwaita detector to remain unavailable") | ||
| 132 | } | 134 | } | ||
| 133 | } | 135 | } | ||
| 136 | + | ||||
| 137 | + | func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) { | |||
| 138 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |||
| 139 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil) | |||
| 140 | + | ||||
| 141 | + | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |||
| 142 | + | ||||
| 143 | + | if err != nil { | |||
| 144 | + | t.Fatalf("expected no error, got %v", err) | |||
| 145 | + | } | |||
| 146 | + | if !forwarded { | |||
| 147 | + | t.Fatal("expected browse URL to be forwarded") | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) { | |||
| 152 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |||
| 153 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil) | |||
| 154 | + | ||||
| 155 | + | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |||
| 156 | + | ||||
| 157 | + | if err != nil { | |||
| 158 | + | t.Fatalf("expected no error, got %v", err) | |||
| 159 | + | } | |||
| 160 | + | if forwarded { | |||
| 161 | + | t.Fatal("expected browse URL to remain unforwarded") | |||
| 162 | + | } | |||
| 163 | + | } | |||
A internal/infrastructure/desktop/browser_launcher.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // BrowserLauncher opens URLs in a relay-first dumber browser instance. | |
| 13 | + | type BrowserLauncher struct { | |
| 14 | + | relay port.BrowserLaunchRelay | |
| 15 | + | resolveExecutablePath func() (string, error) | |
| 16 | + | startDetachedProcess func(*exec.Cmd) error | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewBrowserLauncher creates a browser launcher that prefers relay handoff. | |
| 20 | + | func NewBrowserLauncher(relay port.BrowserLaunchRelay) *BrowserLauncher { | |
| 21 | + | return &BrowserLauncher{ | |
| 22 | + | relay: relay, | |
| 23 | + | resolveExecutablePath: getExecutablePath, | |
| 24 | + | startDetachedProcess: startDetachedProcess, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // LaunchURL forwards the URL to a running instance when possible, otherwise spawns a new one. | |
| 29 | + | func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) { | |
| 30 | + | if l == nil { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | if l.relay != nil { | |
| 35 | + | delivered, err := l.relay.DeliverOpenFreshWindow(ctx, url) | |
| 36 | + | if delivered { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | if err != nil { | |
| 40 | + | fmt.Fprintf(os.Stderr, "failed to forward dumber browser URL %q: %v\n", url, err) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | resolveExecutablePath := l.resolveExecutablePath | |
| 45 | + | if resolveExecutablePath == nil { | |
| 46 | + | resolveExecutablePath = getExecutablePath | |
| 47 | + | } | |
| 48 | + | execPath, err := resolveExecutablePath() | |
| 49 | + | if err != nil { | |
| 50 | + | fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", url, err) | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | cmd := exec.Command(execPath, "browse", url) | |
| 55 | + | cmd.Env = sanitizedChildEnv(os.Environ()) | |
| 56 | + | ||
| 57 | + | spawnDetachedProcess := l.startDetachedProcess | |
| 58 | + | if spawnDetachedProcess == nil { | |
| 59 | + | spawnDetachedProcess = startDetachedProcess | |
| 60 | + | } | |
| 61 | + | if err := spawnDetachedProcess(cmd); err != nil { | |
| 62 | + | fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", url, err) | |
| 63 | + | } | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package desktop | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // BrowserLauncher opens URLs in a relay-first dumber browser instance. | |||
| 13 | + | type BrowserLauncher struct { | |||
| 14 | + | relay port.BrowserLaunchRelay | |||
| 15 | + | resolveExecutablePath func() (string, error) | |||
| 16 | + | startDetachedProcess func(*exec.Cmd) error | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // NewBrowserLauncher creates a browser launcher that prefers relay handoff. | |||
| 20 | + | func NewBrowserLauncher(relay port.BrowserLaunchRelay) *BrowserLauncher { | |||
| 21 | + | return &BrowserLauncher{ | |||
| 22 | + | relay: relay, | |||
| 23 | + | resolveExecutablePath: getExecutablePath, | |||
| 24 | + | startDetachedProcess: startDetachedProcess, | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // LaunchURL forwards the URL to a running instance when possible, otherwise spawns a new one. | |||
| 29 | + | func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) { | |||
| 30 | + | if l == nil { | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | if l.relay != nil { | |||
| 35 | + | delivered, err := l.relay.DeliverOpenFreshWindow(ctx, url) | |||
| 36 | + | if delivered { | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | if err != nil { | |||
| 40 | + | fmt.Fprintf(os.Stderr, "failed to forward dumber browser URL %q: %v\n", url, err) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | resolveExecutablePath := l.resolveExecutablePath | |||
| 45 | + | if resolveExecutablePath == nil { | |||
| 46 | + | resolveExecutablePath = getExecutablePath | |||
| 47 | + | } | |||
| 48 | + | execPath, err := resolveExecutablePath() | |||
| 49 | + | if err != nil { | |||
| 50 | + | fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", url, err) | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | cmd := exec.Command(execPath, "browse", url) | |||
| 55 | + | cmd.Env = sanitizedChildEnv(os.Environ()) | |||
| 56 | + | ||||
| 57 | + | spawnDetachedProcess := l.startDetachedProcess | |||
| 58 | + | if spawnDetachedProcess == nil { | |||
| 59 | + | spawnDetachedProcess = startDetachedProcess | |||
| 60 | + | } | |||
| 61 | + | if err := spawnDetachedProcess(cmd); err != nil { | |||
| 62 | + | fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", url, err) | |||
| 63 | + | } | |||
| 64 | + | } | |||
A internal/infrastructure/desktop/browser_launcher_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os/exec" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) { | |
| 14 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 15 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil) | |
| 16 | + | ||
| 17 | + | launcher := NewBrowserLauncher(relay) | |
| 18 | + | spawned := false | |
| 19 | + | launcher.resolveExecutablePath = func() (string, error) { | |
| 20 | + | t.Fatal("expected executable path resolution to be skipped") | |
| 21 | + | return "", nil | |
| 22 | + | } | |
| 23 | + | launcher.startDetachedProcess = func(cmd *exec.Cmd) error { | |
| 24 | + | spawned = true | |
| 25 | + | return nil | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | launcher.LaunchURL(context.Background(), "https://example.com") | |
| 29 | + | ||
| 30 | + | assert.False(t, spawned) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) { | |
| 34 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 35 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil) | |
| 36 | + | ||
| 37 | + | launcher := NewBrowserLauncher(relay) | |
| 38 | + | launcher.resolveExecutablePath = func() (string, error) { | |
| 39 | + | return "/usr/bin/dumber", nil | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | var gotCmd *exec.Cmd | |
| 43 | + | launcher.startDetachedProcess = func(cmd *exec.Cmd) error { | |
| 44 | + | gotCmd = cmd | |
| 45 | + | return nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | launcher.LaunchURL(context.Background(), "https://example.com") | |
| 49 | + | ||
| 50 | + | require.NotNil(t, gotCmd) | |
| 51 | + | assert.Equal(t, "/usr/bin/dumber", gotCmd.Path) | |
| 52 | + | assert.Equal(t, []string{"/usr/bin/dumber", "browse", "https://example.com"}, gotCmd.Args) | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | package desktop | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os/exec" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) { | |||
| 14 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |||
| 15 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil) | |||
| 16 | + | ||||
| 17 | + | launcher := NewBrowserLauncher(relay) | |||
| 18 | + | spawned := false | |||
| 19 | + | launcher.resolveExecutablePath = func() (string, error) { | |||
| 20 | + | t.Fatal("expected executable path resolution to be skipped") | |||
| 21 | + | return "", nil | |||
| 22 | + | } | |||
| 23 | + | launcher.startDetachedProcess = func(cmd *exec.Cmd) error { | |||
| 24 | + | spawned = true | |||
| 25 | + | return nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | launcher.LaunchURL(context.Background(), "https://example.com") | |||
| 29 | + | ||||
| 30 | + | assert.False(t, spawned) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) { | |||
| 34 | + | relay := mocks.NewMockBrowserLaunchRelay(t) | |||
| 35 | + | relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil) | |||
| 36 | + | ||||
| 37 | + | launcher := NewBrowserLauncher(relay) | |||
| 38 | + | launcher.resolveExecutablePath = func() (string, error) { | |||
| 39 | + | return "/usr/bin/dumber", nil | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | var gotCmd *exec.Cmd | |||
| 43 | + | launcher.startDetachedProcess = func(cmd *exec.Cmd) error { | |||
| 44 | + | gotCmd = cmd | |||
| 45 | + | return nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | launcher.LaunchURL(context.Background(), "https://example.com") | |||
| 49 | + | ||||
| 50 | + | require.NotNil(t, gotCmd) | |||
| 51 | + | assert.Equal(t, "/usr/bin/dumber", gotCmd.Path) | |||
| 52 | + | assert.Equal(t, []string{"/usr/bin/dumber", "browse", "https://example.com"}, gotCmd.Args) | |||
| 53 | + | } | |||