From a3ea90933781dd37e638c9512d9da08e1f223267 Mon Sep 17 00:00:00 2001 From: George Tsiolis <120486+gtsiolis@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:03:52 +0000 Subject: [PATCH 1/2] test: stop login tests from opening real browser tabs (DEVX-982) The device-flow login opens the auth URL in a real browser via browser.OpenURL. The UI and integration login tests exercise the real login flow, so running `make test`/`make test-integration` on a machine with a GUI spawned real browser tabs (invisible in CI, where the open fails silently). Add two seams: - Unit: make the opener injectable via a new auth.WithBrowserOpener option on auth.New (loginProvider.openBrowser defaults to browser.OpenURL). The UI tests inject a recorder and assert the URL that would have been opened. - Integration (real binary, no Go seam): prepend a temp dir to PATH with fake open/xdg-open scripts that record the URL to a file, then assert it. No production-code changes needed there. Generated with [Linear](https://linear.app/localstack/issue/DEVX-982/make-test-opens-real-browser-tabs#agent-session-19a20b08) Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- internal/auth/auth.go | 19 ++++++++++++++++-- internal/auth/login.go | 12 +++++++++--- internal/ui/run_login_test.go | 36 +++++++++++++++++++++++++++++++--- test/integration/env/env.go | 1 + test/integration/login_test.go | 30 ++++++++++++++++++++++++++-- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 873ab3fb..c1680e39 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -21,10 +21,25 @@ type Auth struct { licenseFilePath string } -func New(sink output.Sink, platform api.PlatformAPI, storage AuthTokenStorage, authToken, webAppURL string, allowLogin bool, licenseFilePath string) *Auth { +// Option configures optional behavior of the login flow. +type Option func(*loginProvider) + +// WithBrowserOpener overrides how the login flow opens the auth URL in a +// browser. Tests inject a recorder so they don't spawn real browser tabs. +func WithBrowserOpener(open func(string) error) Option { + return func(lp *loginProvider) { + lp.openBrowser = open + } +} + +func New(sink output.Sink, platform api.PlatformAPI, storage AuthTokenStorage, authToken, webAppURL string, allowLogin bool, licenseFilePath string, opts ...Option) *Auth { + lp := newLoginProvider(sink, platform, webAppURL) + for _, opt := range opts { + opt(lp) + } return &Auth{ tokenStorage: storage, - login: newLoginProvider(sink, platform, webAppURL), + login: lp, sink: sink, authToken: authToken, allowLogin: allowLogin, diff --git a/internal/auth/login.go b/internal/auth/login.go index ecff3a6c..84374605 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -22,6 +22,13 @@ type loginProvider struct { platformClient api.PlatformAPI sink output.Sink webAppURL string + openBrowser func(string) error +} + +func defaultOpenBrowser(url string) error { + browser.Stdout = io.Discard + browser.Stderr = io.Discard + return browser.OpenURL(url) } func newLoginProvider(sink output.Sink, platformClient api.PlatformAPI, webAppURL string) *loginProvider { @@ -29,6 +36,7 @@ func newLoginProvider(sink output.Sink, platformClient api.PlatformAPI, webAppUR platformClient: platformClient, sink: sink, webAppURL: webAppURL, + openBrowser: defaultOpenBrowser, } } @@ -45,9 +53,7 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) { Code: authReq.Code, URL: authURL, }) - browser.Stdout = io.Discard - browser.Stderr = io.Discard - if err := browser.OpenURL(authURL); err != nil { + if err := l.openBrowser(authURL); err != nil { l.sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: fmt.Sprintf("Failed to open browser automatically. Open this URL manually to continue: %s", authURL)}) } diff --git a/internal/ui/run_login_test.go b/internal/ui/run_login_test.go index 81b5eb83..2f3e4396 100644 --- a/internal/ui/run_login_test.go +++ b/internal/ui/run_login_test.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/http/httptest" + "sync" "testing" "time" @@ -80,6 +81,26 @@ func readOutput(r io.Reader) string { return string(b) } +// browserRecorder captures the URL the login flow would have opened instead of +// spawning a real browser tab. +type browserRecorder struct { + mu sync.Mutex + url string +} + +func (b *browserRecorder) open(url string) error { + b.mu.Lock() + defer b.mu.Unlock() + b.url = url + return nil +} + +func (b *browserRecorder) opened() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.url +} + func TestLoginFlow_DeviceFlowSuccess(t *testing.T) { mockServer := createMockAPIServer(t, "test-license-token", true) defer mockServer.Close() @@ -95,10 +116,11 @@ func TestLoginFlow_DeviceFlowSuccess(t *testing.T) { tm := teatest.NewTestModel(t, NewApp("test", "", "", cancel), teatest.WithInitialTermSize(120, 40)) sender := testModelSender{tm: tm} platformClient := api.NewPlatformClient(mockServer.URL, log.Nop()) + recorder := &browserRecorder{} errCh := make(chan error, 1) go func() { - a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "") + a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "", auth.WithBrowserOpener(recorder.open)) _, err := a.GetToken(ctx) errCh <- err if err != nil && !errors.Is(err, context.Canceled) { @@ -112,6 +134,8 @@ func TestLoginFlow_DeviceFlowSuccess(t *testing.T) { return bytes.Contains(bts, []byte("Waiting for authorization")) }, teatest.WithDuration(5*time.Second)) + assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", recorder.opened()) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) select { @@ -143,10 +167,11 @@ func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) { tm := teatest.NewTestModel(t, NewApp("test", "", "", cancel), teatest.WithInitialTermSize(120, 40)) sender := testModelSender{tm: tm} platformClient := api.NewPlatformClient(mockServer.URL, log.Nop()) + recorder := &browserRecorder{} errCh := make(chan error, 1) go func() { - a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "") + a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "", auth.WithBrowserOpener(recorder.open)) _, err := a.GetToken(ctx) errCh <- err if err != nil && !errors.Is(err, context.Canceled) { @@ -160,6 +185,8 @@ func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) { return bytes.Contains(bts, []byte("Waiting for authorization")) }, teatest.WithDuration(5*time.Second)) + assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", recorder.opened()) + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) select { @@ -192,10 +219,11 @@ func TestLoginFlow_DeviceFlowCancelWithCtrlC(t *testing.T) { tm := teatest.NewTestModel(t, NewApp("test", "", "", cancel), teatest.WithInitialTermSize(120, 40)) sender := testModelSender{tm: tm} platformClient := api.NewPlatformClient(mockServer.URL, log.Nop()) + recorder := &browserRecorder{} errCh := make(chan error, 1) go func() { - a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "") + a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, "", mockServer.URL, true, "", auth.WithBrowserOpener(recorder.open)) _, err := a.GetToken(ctx) errCh <- err if err != nil && !errors.Is(err, context.Canceled) { @@ -209,6 +237,8 @@ func TestLoginFlow_DeviceFlowCancelWithCtrlC(t *testing.T) { return bytes.Contains(bts, []byte("Waiting for authorization")) }, teatest.WithDuration(5*time.Second)) + assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", recorder.opened()) + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) select { diff --git a/test/integration/env/env.go b/test/integration/env/env.go index 4c4467b5..30f505c3 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -18,6 +18,7 @@ const ( DisableEvents Key = "LOCALSTACK_DISABLE_EVENTS" Home Key = "HOME" UserProfile Key = "USERPROFILE" + Path Key = "PATH" Persistence Key = "LOCALSTACK_PERSISTENCE" Otel Key = "LSTK_OTEL" OtelEndpoint Key = "OTEL_EXPORTER_OTLP_ENDPOINT" diff --git a/test/integration/login_test.go b/test/integration/login_test.go index d4be99ad..c91fb30b 100644 --- a/test/integration/login_test.go +++ b/test/integration/login_test.go @@ -77,6 +77,26 @@ func createMockAPIServer(t *testing.T, licenseToken string, confirmed bool) *htt })) } +// fakeBrowserOpener prepends a temp dir to PATH containing fake open/xdg-open +// scripts that record the URL they were asked to open instead of spawning a real +// browser tab. It returns the augmented environment and a reader for the recorded +// URL. The scripts cover the providers github.com/pkg/browser tries on macOS +// (open) and Linux (xdg-open, x-www-browser, www-browser). +func fakeBrowserOpener(t *testing.T, environ env.Environ) (env.Environ, func() string) { + t.Helper() + dir := t.TempDir() + record := filepath.Join(dir, "opened-url") + script := fmt.Sprintf("#!/bin/sh\nprintf '%%s' \"$1\" > %q\n", record) + for _, name := range []string{"open", "xdg-open", "x-www-browser", "www-browser"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755)) + } + environ = environ.With(env.Path, dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return environ, func() string { + b, _ := os.ReadFile(record) + return string(b) + } +} + func TestDeviceFlowSuccess(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("PTY not supported on Windows") @@ -96,8 +116,10 @@ func TestDeviceFlowSuccess(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + environ, openedURL := fakeBrowserOpener(t, env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL)) + cmd := exec.CommandContext(ctx, binaryPath(), "login") - cmd.Env = env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL) + cmd.Env = environ ptmx, err := pty.Start(cmd) require.NoError(t, err, "failed to start command in PTY") @@ -124,6 +146,7 @@ func TestDeviceFlowSuccess(t *testing.T) { out := output.String() require.NoError(t, err, "login should succeed: %s", out) requireExitCode(t, 0, err) + assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", openedURL(), "login should open the auth URL in a browser") assert.Contains(t, out, "Opening browser to login...") assert.Contains(t, out, "Browser didn't open? Visit") assert.Contains(t, out, "/auth/request/test-auth-req-id?code=TEST123") @@ -156,8 +179,10 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + environ, openedURL := fakeBrowserOpener(t, env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL)) + cmd := exec.CommandContext(ctx, binaryPath(), "login") - cmd.Env = env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL) + cmd.Env = environ ptmx, err := pty.Start(cmd) require.NoError(t, err, "failed to start command in PTY") @@ -184,6 +209,7 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { out := output.String() require.Error(t, err, "expected login to fail when request not confirmed") requireExitCode(t, 1, err) + assert.Equal(t, mockServer.URL+"/auth/request/test-auth-req-id?code=TEST123", openedURL(), "login should open the auth URL in a browser") assert.Contains(t, out, "Opening browser to login...") assert.Contains(t, out, "Browser didn't open? Visit") assert.Contains(t, out, "/auth/request/test-auth-req-id?code=TEST123") From 0d73c855059faf210601711c2a7a5195139c5cd8 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Thu, 9 Jul 2026 12:17:02 +0300 Subject: [PATCH 2/2] test: point login integration tests at the mock web app URL (DEVX-982) The device-flow tests overrode LSTK_API_ENDPOINT but the auth URL opened in the browser is built from LSTK_WEB_APP_URL, which stayed at the production default - so the recorded URL was app.localstack.cloud, not the mock server. Point the web app URL at the mock server too, making the tests fully hermetic. --- test/integration/env/env.go | 1 + test/integration/login_test.go | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/test/integration/env/env.go b/test/integration/env/env.go index 30f505c3..9f2772a5 100644 --- a/test/integration/env/env.go +++ b/test/integration/env/env.go @@ -12,6 +12,7 @@ const ( AuthToken Key = "LOCALSTACK_AUTH_TOKEN" LocalStackHost Key = "LOCALSTACK_HOST" APIEndpoint Key = "LSTK_API_ENDPOINT" + WebAppURL Key = "LSTK_WEB_APP_URL" Keyring Key = "LSTK_KEYRING" CI Key = "CI" AnalyticsEndpoint Key = "LSTK_ANALYTICS_ENDPOINT" diff --git a/test/integration/login_test.go b/test/integration/login_test.go index c91fb30b..e2a71eb3 100644 --- a/test/integration/login_test.go +++ b/test/integration/login_test.go @@ -116,7 +116,7 @@ func TestDeviceFlowSuccess(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - environ, openedURL := fakeBrowserOpener(t, env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL)) + environ, openedURL := fakeBrowserOpener(t, env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.WebAppURL, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL)) cmd := exec.CommandContext(ctx, binaryPath(), "login") cmd.Env = environ @@ -179,7 +179,7 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - environ, openedURL := fakeBrowserOpener(t, env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL)) + environ, openedURL := fakeBrowserOpener(t, env.Without(env.AuthToken).With(env.APIEndpoint, mockServer.URL).With(env.WebAppURL, mockServer.URL).With(env.AnalyticsEndpoint, analyticsSrv.URL)) cmd := exec.CommandContext(ctx, binaryPath(), "login") cmd.Env = environ