From d36e7f632df9cebf301be44cfa637206a79dbf55 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Wed, 4 Mar 2026 00:17:23 +0200 Subject: [PATCH] Rename InstructionsEvent to AuthEvent --- go.mod | 2 +- go.sum | 2 - internal/auth/auth.go | 4 +- internal/auth/login.go | 40 ++---- internal/output/events.go | 12 +- internal/output/events_test.go | 47 +++++++ internal/output/plain_format.go | 55 ++++++-- internal/output/plain_format_test.go | 12 ++ internal/output/plain_sink_test.go | 3 + internal/ui/app.go | 184 ++++++++++++++++++++----- internal/ui/app_test.go | 171 +++++++++++++++++++++-- internal/ui/components/input_prompt.go | 16 +-- internal/ui/components/spinner.go | 7 +- internal/ui/run_login_test.go | 77 ++++++++--- internal/ui/styles/styles.go | 20 ++- test/integration/login_test.go | 40 ++---- 16 files changed, 548 insertions(+), 144 deletions(-) create mode 100644 internal/output/events_test.go diff --git a/go.mod b/go.mod index 0299640..23886f2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/99designs/keyring v1.2.2 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/teatest v0.0.0-20260216111343-536eb63c1f4c @@ -27,7 +28,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.3.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect diff --git a/go.sum b/go.sum index 6042a51..2d2f382 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,6 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/teatest v0.0.0-20260216111343-536eb63c1f4c h1:/pbU92+xMwttewB4XK69/B9ISH0HMhOMrTIVhV4AS7M= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ac56ba1..835d402 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -42,9 +42,11 @@ func (a *Auth) GetToken(ctx context.Context) (string, error) { return "", fmt.Errorf("authentication required: set LOCALSTACK_AUTH_TOKEN or run in interactive mode") } - output.EmitInfo(a.sink, "No existing credentials found. Please log in:") token, err := a.login.Login(ctx) if err != nil { + if errors.Is(err, context.Canceled) { + return "", err + } output.EmitWarning(a.sink, "Authentication failed.") return "", err } diff --git a/internal/auth/login.go b/internal/auth/login.go index c3cc3ef..b5f8161 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -35,46 +35,32 @@ func (l *loginProvider) Login(ctx context.Context) (string, error) { } authURL := fmt.Sprintf("%s/auth/request/%s", getWebAppURL(), authReq.ID) - output.EmitInfo(l.sink, fmt.Sprintf("Visit: %s", authURL)) - output.EmitInfo(l.sink, fmt.Sprintf("Verification code: %s", authReq.Code)) - // Ask whether to open the browser; ENTER or Y accepts (default yes), N skips - browserCh := make(chan output.InputResponse, 1) - output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{ - Prompt: "Open browser now?", - Options: []output.InputOption{{Key: "y", Label: "Y"}, {Key: "n", Label: "n"}}, - ResponseCh: browserCh, + output.EmitAuth(l.sink, output.AuthEvent{ + Preamble: "Welcome to lstk, a command-line interface for LocalStack", + Code: authReq.Code, + URL: authURL, }) + _ = browser.OpenURL(authURL) - select { - case resp := <-browserCh: - if resp.Cancelled { - return "", context.Canceled - } - if resp.SelectedKey != "n" { - if err := browser.OpenURL(authURL); err != nil { - output.EmitWarning(l.sink, fmt.Sprintf("Failed to open browser: %v", err)) - } - } - case <-ctx.Done(): - return "", ctx.Err() - } + output.EmitSpinnerStart(l.sink, "Waiting for authorization...") - // Wait for the user to complete authentication in the browser - enterCh := make(chan output.InputResponse, 1) + responseCh := make(chan output.InputResponse, 1) output.EmitUserInputRequest(l.sink, output.UserInputRequestEvent{ - Prompt: "Waiting for authentication", - Options: []output.InputOption{{Key: "enter", Label: "Press ENTER when complete"}}, - ResponseCh: enterCh, + Prompt: "Waiting for authorization...", + Options: []output.InputOption{{Key: "any", Label: "Press any key when complete"}}, + ResponseCh: responseCh, }) select { - case resp := <-enterCh: + case resp := <-responseCh: + output.EmitSpinnerStop(l.sink) if resp.Cancelled { return "", context.Canceled } return l.completeAuth(ctx, authReq) case <-ctx.Done(): + output.EmitSpinnerStop(l.sink) return "", ctx.Err() } } diff --git a/internal/output/events.go b/internal/output/events.go index b6fc459..11c58d8 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -49,8 +49,14 @@ type ErrorEvent struct { Actions []ErrorAction } +type AuthEvent struct { + Preamble string + Code string + URL string +} + type Event interface { - MessageEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent + MessageEvent | AuthEvent | SpinnerEvent | ErrorEvent | ContainerStatusEvent | ProgressEvent | UserInputRequestEvent | ContainerLogLineEvent } type Sink interface { @@ -143,6 +149,10 @@ func EmitUserInputRequest(sink Sink, event UserInputRequestEvent) { Emit(sink, event) } +func EmitAuth(sink Sink, event AuthEvent) { + Emit(sink, event) +} + func EmitContainerLogLine(sink Sink, line string) { Emit(sink, ContainerLogLineEvent{Line: line}) } diff --git a/internal/output/events_test.go b/internal/output/events_test.go new file mode 100644 index 0000000..25785dc --- /dev/null +++ b/internal/output/events_test.go @@ -0,0 +1,47 @@ +package output + +import "testing" + +type captureSink struct { + events []any +} + +func (s *captureSink) emit(event any) { + s.events = append(s.events, event) +} + +func TestEmitAuth(t *testing.T) { + t.Parallel() + + sink := &captureSink{} + EmitAuth(sink, AuthEvent{ + Preamble: "Welcome", + Code: "ABC123", + URL: "https://example.com", + }) + + if len(sink.events) != 1 { + t.Fatalf("expected 1 event, got %d", len(sink.events)) + } + event, ok := sink.events[0].(AuthEvent) + if !ok { + t.Fatalf("expected AuthEvent, got %T", sink.events[0]) + } + if event.Code != "ABC123" { + t.Fatalf("expected code %q, got %q", "ABC123", event.Code) + } + if event.URL != "https://example.com" { + t.Fatalf("expected URL %q, got %q", "https://example.com", event.URL) + } + if event.Preamble != "Welcome" { + t.Fatalf("expected preamble %q, got %q", "Welcome", event.Preamble) + } + + line, ok := FormatEventLine(event) + if !ok { + t.Fatal("expected formatter output") + } + if line != "Welcome\nYour one-time code: ABC123\nOpening browser to login...\nhttps://example.com" { + t.Fatalf("unexpected formatted line: %q", line) + } +} diff --git a/internal/output/plain_format.go b/internal/output/plain_format.go index 3853dc3..ae363b6 100644 --- a/internal/output/plain_format.go +++ b/internal/output/plain_format.go @@ -10,6 +10,8 @@ func FormatEventLine(event any) (string, bool) { switch e := event.(type) { case MessageEvent: return formatMessageEvent(e), true + case AuthEvent: + return formatAuthEvent(e), true case SpinnerEvent: if e.Active { return e.Text + "...", true @@ -63,18 +65,55 @@ func formatProgressLine(e ProgressEvent) (string, bool) { } func formatUserInputRequest(e UserInputRequestEvent) string { - switch len(e.Options) { + return FormatPrompt(e.Prompt, e.Options) +} + +// FormatPrompt formats a prompt string with its options into a display line. +func FormatPrompt(prompt string, options []InputOption) string { + lines := strings.Split(prompt, "\n") + firstLine := lines[0] + rest := lines[1:] + labels := make([]string, 0, len(options)) + for _, opt := range options { + if opt.Label != "" { + labels = append(labels, opt.Label) + } + } + + switch len(labels) { case 0: - return e.Prompt + if len(rest) == 0 { + return firstLine + } + return strings.Join(append([]string{firstLine}, rest...), "\n") case 1: - return fmt.Sprintf("%s (%s)", e.Prompt, e.Options[0].Label) + firstLine = fmt.Sprintf("%s (%s)", firstLine, labels[0]) default: - labels := make([]string, len(e.Options)) - for i, opt := range e.Options { - labels[i] = opt.Label - } - return fmt.Sprintf("%s [%s]", e.Prompt, strings.Join(labels, "/")) + firstLine = fmt.Sprintf("%s [%s]", firstLine, strings.Join(labels, "/")) + } + + if len(rest) == 0 { + return firstLine + } + return strings.Join(append([]string{firstLine}, rest...), "\n") +} + +func formatAuthEvent(e AuthEvent) string { + var sb strings.Builder + if e.Preamble != "" { + sb.WriteString(e.Preamble) + sb.WriteString("\n") + } + if e.Code != "" { + sb.WriteString("Your one-time code: ") + sb.WriteString(e.Code) + sb.WriteString("\n") + } + if e.URL != "" { + sb.WriteString("Opening browser to login...\n") + sb.WriteString(e.URL) } + return strings.TrimRight(sb.String(), "\n") } func formatMessageEvent(e MessageEvent) string { diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 134b61c..3abf1e3 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -35,6 +35,18 @@ func TestFormatEventLine(t *testing.T) { want: "> Warning: careful", wantOK: true, }, + { + name: "instructions event full", + event: AuthEvent{Preamble: "Welcome", Code: "ABC123", URL: "https://example.com"}, + want: "Welcome\nYour one-time code: ABC123\nOpening browser to login...\nhttps://example.com", + wantOK: true, + }, + { + name: "instructions event code only", + event: AuthEvent{Code: "XYZ"}, + want: "Your one-time code: XYZ", + wantOK: true, + }, { name: "status pulling", event: ContainerStatusEvent{Phase: "pulling", Container: "localstack/localstack:latest"}, diff --git a/internal/output/plain_sink_test.go b/internal/output/plain_sink_test.go index 0dd9cb6..b3bd661 100644 --- a/internal/output/plain_sink_test.go +++ b/internal/output/plain_sink_test.go @@ -219,6 +219,7 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) { MessageEvent{Severity: SeverityWarning, Text: "careful"}, MessageEvent{Severity: SeveritySuccess, Text: "done"}, MessageEvent{Severity: SeverityNote, Text: "fyi"}, + AuthEvent{Code: "ABC123", URL: "https://example.com"}, SpinnerEvent{Active: true, Text: "Loading"}, ErrorEvent{Title: "Failed", Summary: "Something broke"}, ContainerStatusEvent{Phase: "starting", Container: "localstack"}, @@ -232,6 +233,8 @@ func TestPlainSink_UsesFormatterParity(t *testing.T) { switch e := event.(type) { case MessageEvent: Emit(sink, e) + case AuthEvent: + Emit(sink, e) case SpinnerEvent: Emit(sink, e) case ErrorEvent: diff --git a/internal/ui/app.go b/internal/ui/app.go index deaa664..77e1b92 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2,12 +2,14 @@ package ui import ( "context" + "fmt" "strings" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/components" + "github.com/localstack/lstk/internal/ui/styles" ) const maxLines = 200 @@ -18,17 +20,24 @@ type runErrMsg struct { err error } +type styledLine struct { + text string + highlight bool + secondary bool +} + type App struct { - header components.Header - inputPrompt components.InputPrompt - spinner components.Spinner - errorDisplay components.ErrorDisplay - lines []string - bufferedLines []string // lines waiting for spinner to finish - cancel func() - pendingInput *output.UserInputRequestEvent - err error - quitting bool + header components.Header + inputPrompt components.InputPrompt + spinner components.Spinner + errorDisplay components.ErrorDisplay + lines []styledLine + bufferedLines []styledLine // lines waiting for spinner to finish + width int + cancel func() + pendingInput *output.UserInputRequestEvent + err error + quitting bool } func NewApp(version, emulatorName, endpoint string, cancel func()) App { @@ -37,7 +46,7 @@ func NewApp(version, emulatorName, endpoint string, cancel func()) App { inputPrompt: components.NewInputPrompt(), spinner: components.NewSpinner(), errorDisplay: components.NewErrorDisplay(), - lines: make([]string, 0, maxLines), + lines: make([]styledLine, 0, maxLines), cancel: cancel, } } @@ -69,20 +78,31 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Quit } if a.pendingInput != nil { + // "any" option: any keypress resolves the prompt + for _, opt := range a.pendingInput.Options { + if opt.Key == "any" { + a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, "any")}) + responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: "any"}) + a.pendingInput = nil + a.inputPrompt = a.inputPrompt.Hide() + return a, responseCmd + } + } if msg.Type == tea.KeyEnter { - // ENTER selects the first option (default) - selectedKey := "" - if len(a.pendingInput.Options) > 0 { - selectedKey = a.pendingInput.Options[0].Key + for _, opt := range a.pendingInput.Options { + if opt.Key == "enter" { + a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, "enter")}) + responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: "enter"}) + a.pendingInput = nil + a.inputPrompt = a.inputPrompt.Hide() + return a, responseCmd + } } - responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: selectedKey}) - a.pendingInput = nil - a.inputPrompt = a.inputPrompt.Hide() - return a, responseCmd + return a, nil } - // A single character key press selects the matching option for _, opt := range a.pendingInput.Options { if msg.String() == opt.Key { + a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)}) responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key}) a.pendingInput = nil a.inputPrompt = a.inputPrompt.Hide() @@ -90,9 +110,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + case tea.WindowSizeMsg: + a.width = msg.Width case output.UserInputRequestEvent: a.pendingInput = &msg - a.inputPrompt = a.inputPrompt.Show(msg.Prompt, msg.Options) + if a.spinner.Visible() { + a.spinner = a.spinner.SetText(output.FormatPrompt(msg.Prompt, msg.Options)) + } else { + a.inputPrompt = a.inputPrompt.Show(msg.Prompt, msg.Options) + } case spinner.TickMsg: var cmd tea.Cmd a.spinner, cmd = a.spinner.Update(msg) @@ -123,13 +149,26 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.spinner, _ = a.spinner.Stop() return a, nil case output.MessageEvent: - line := components.RenderMessage(msg) + line := styledLine{text: components.RenderMessage(msg)} if a.spinner.PendingStop() { a.bufferedLines = append(a.bufferedLines, line) } else { a.lines = appendLine(a.lines, line) } return a, nil + case output.AuthEvent: + if msg.Preamble != "" { + a.lines = appendLine(a.lines, styledLine{text: "> " + msg.Preamble, secondary: true}) + } + if msg.Code != "" { + a.lines = appendLine(a.lines, styledLine{text: "Your one-time code:"}) + a.lines = appendLine(a.lines, styledLine{text: msg.Code, highlight: true}) + } + if msg.URL != "" { + a.lines = appendLine(a.lines, styledLine{text: "Opening browser to login..."}) + a.lines = appendLine(a.lines, styledLine{text: msg.URL, secondary: true}) + } + return a, nil case runDoneMsg: if a.spinner.PendingStop() { a.quitting = true @@ -138,10 +177,12 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Quit case runErrMsg: a.err = msg.err + a.spinner, _ = a.spinner.Stop() + a.flushBufferedLines() return a, tea.Quit default: if line, ok := output.FormatEventLine(msg); ok { - a.lines = appendLine(a.lines, line) + a.lines = appendLine(a.lines, styledLine{text: line}) } } @@ -161,7 +202,7 @@ func sendInputResponseCmd(responseCh chan<- output.InputResponse, response outpu } } -func appendLine(lines []string, line string) []string { +func appendLine(lines []styledLine, line styledLine) []styledLine { lines = append(lines, line) if len(lines) > maxLines { lines = lines[len(lines)-maxLines:] @@ -176,25 +217,100 @@ func (a *App) flushBufferedLines() { a.bufferedLines = nil } +func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) string { + formatted := output.FormatPrompt(req.Prompt, req.Options) + firstLine := strings.Split(formatted, "\n")[0] + + selected := selectedKey + hasLabels := false + for _, opt := range req.Options { + if opt.Label != "" { + hasLabels = true + } + if opt.Key == selectedKey && opt.Label != "" { + selected = opt.Label + } + } + + if selected == "" || !hasLabels { + return firstLine + } + return fmt.Sprintf("%s %s", firstLine, selected) +} + +const lineIndent = 2 + +func hardWrap(s string, maxWidth int) string { + rs := []rune(s) + if maxWidth <= 0 || len(rs) <= maxWidth { + return s + } + var sb strings.Builder + for i := 0; i < len(rs); i += maxWidth { + if i > 0 { + sb.WriteByte('\n') + } + end := i + maxWidth + if end > len(rs) { + end = len(rs) + } + sb.WriteString(string(rs[i:end])) + } + return sb.String() +} + +func isURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +func hyperlink(url, displayText string) string { + return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", url, displayText) +} + func (a App) View() string { var sb strings.Builder sb.WriteString(a.header.View()) sb.WriteString("\n") - if spinnerView := a.spinner.View(); spinnerView != "" { - sb.WriteString(" ") - sb.WriteString(spinnerView) - sb.WriteString("\n") - } - + indent := strings.Repeat(" ", lineIndent) + contentWidth := a.width - lineIndent for _, line := range a.lines { - sb.WriteString(" ") - sb.WriteString(line) + if line.highlight { + if isURL(line.text) { + wrapped := strings.Split(hardWrap(line.text, contentWidth), "\n") + var styledParts []string + for _, part := range wrapped { + styledParts = append(styledParts, styles.Link.Render(part)) + } + sb.WriteString(indent) + sb.WriteString(hyperlink(line.text, strings.Join(styledParts, "\n"+indent))) + } else { + sb.WriteString(indent) + sb.WriteString(styles.Highlight.Render(hardWrap(line.text, contentWidth))) + } + sb.WriteString("\n\n") + continue + } else if line.secondary { + if strings.HasPrefix(line.text, ">") { + sb.WriteString(styles.SecondaryMessage.Render(hardWrap(line.text, contentWidth))) + sb.WriteString("\n\n") + continue + } + sb.WriteString(indent) + text := hardWrap(line.text, contentWidth) + sb.WriteString(styles.SecondaryMessage.Render(text)) + } else { + sb.WriteString(indent) + text := hardWrap(line.text, contentWidth) + sb.WriteString(text) + } sb.WriteString("\n") } - if promptView := a.inputPrompt.View(); promptView != "" { - sb.WriteString(" ") + if spinnerView := a.spinner.View(); spinnerView != "" { + sb.WriteString(spinnerView) + sb.WriteString("\n") + } else if promptView := a.inputPrompt.View(); promptView != "" { sb.WriteString(promptView) sb.WriteString("\n") } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 9a49ab9..d25b83c 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -65,7 +65,6 @@ func TestAppEnterRespondsToInputRequest(t *testing.T) { app := NewApp("dev", "", "", nil) - // First, send a user input request responseCh := make(chan output.InputResponse, 1) model, _ := app.Update(output.UserInputRequestEvent{ Prompt: "Press enter", @@ -74,12 +73,10 @@ func TestAppEnterRespondsToInputRequest(t *testing.T) { }) app = model.(App) - // Verify input prompt is visible if !app.inputPrompt.Visible() { t.Fatal("expected input prompt to be visible") } - // Now send ENTER key model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = model.(App) if cmd == nil { @@ -87,7 +84,6 @@ func TestAppEnterRespondsToInputRequest(t *testing.T) { } cmd() - // Verify response was sent select { case resp := <-responseCh: if resp.SelectedKey != "enter" { @@ -97,7 +93,6 @@ func TestAppEnterRespondsToInputRequest(t *testing.T) { t.Fatal("timed out waiting for response on channel") } - // Verify input prompt is hidden if app.inputPrompt.Visible() { t.Fatal("expected input prompt to be hidden after response") } @@ -109,7 +104,6 @@ func TestAppCtrlCCancelsPendingInput(t *testing.T) { cancelled := false app := NewApp("dev", "", "", func() { cancelled = true }) - // Send a user input request responseCh := make(chan output.InputResponse, 1) model, _ := app.Update(output.UserInputRequestEvent{ Prompt: "Press enter", @@ -118,7 +112,6 @@ func TestAppCtrlCCancelsPendingInput(t *testing.T) { }) app = model.(App) - // Send Ctrl+C model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) app = model.(App) if cmd == nil { @@ -126,7 +119,6 @@ func TestAppCtrlCCancelsPendingInput(t *testing.T) { } cmd() - // Verify cancellation response was sent select { case resp := <-responseCh: if !resp.Cancelled { @@ -181,8 +173,8 @@ func TestAppMessageEventRendering(t *testing.T) { if len(app.lines) != 1 { t.Fatalf("expected 1 line, got %d", len(app.lines)) } - if !strings.Contains(app.lines[0], "Success:") || !strings.Contains(app.lines[0], "Done") { - t.Fatalf("expected rendered success message, got: %q", app.lines[0]) + if !strings.Contains(app.lines[0].text, "Success:") || !strings.Contains(app.lines[0].text, "Done") { + t.Fatalf("expected rendered success message, got: %q", app.lines[0].text) } } @@ -208,3 +200,162 @@ func TestAppErrorEventStopsSpinner(t *testing.T) { t.Fatal("expected error display to be visible after ErrorEvent") } } + +func TestAppEnterPrefersExplicitEnterOption(t *testing.T) { + t.Parallel() + + app := NewApp("dev", "", "", nil) + responseCh := make(chan output.InputResponse, 1) + + model, _ := app.Update(output.UserInputRequestEvent{ + Prompt: "Open browser now?", + Options: []output.InputOption{ + {Key: "y", Label: "Y"}, + {Key: "n", Label: "n"}, + {Key: "enter", Label: "Press ENTER when complete"}, + }, + ResponseCh: responseCh, + }) + app = model.(App) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if cmd == nil { + t.Fatal("expected response command") + } + cmd() + + select { + case resp := <-responseCh: + if resp.SelectedKey != "enter" { + t.Fatalf("expected enter key, got %q", resp.SelectedKey) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for response on channel") + } + + if app.inputPrompt.Visible() { + t.Fatal("expected input prompt to be hidden after response") + } +} + +func TestAppEnterDoesNothingWithoutExplicitEnterOption(t *testing.T) { + t.Parallel() + + app := NewApp("dev", "", "", nil) + responseCh := make(chan output.InputResponse, 1) + + model, _ := app.Update(output.UserInputRequestEvent{ + Prompt: "Open browser now?", + Options: []output.InputOption{ + {Key: "y", Label: "Y"}, + {Key: "n", Label: "n"}, + }, + ResponseCh: responseCh, + }) + app = model.(App) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if cmd != nil { + t.Fatal("expected no response command when enter is not an explicit option") + } + + select { + case resp := <-responseCh: + t.Fatalf("expected no response, got %+v", resp) + case <-time.After(200 * time.Millisecond): + } + + if !app.inputPrompt.Visible() { + t.Fatal("expected input prompt to remain visible") + } +} + +func TestHardWrapHandlesUTF8Runes(t *testing.T) { + t.Parallel() + + got := hardWrap("A🙂BC", 2) + want := "A🙂\nBC" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) { + t.Parallel() + + app := NewApp("dev", "", "", nil) + responseCh := make(chan output.InputResponse, 1) + + model, _ := app.Update(output.UserInputRequestEvent{ + Prompt: "Waiting for authorization...", + Options: []output.InputOption{{Key: "any", Label: "Press any key when complete"}}, + ResponseCh: responseCh, + }) + app = model.(App) + + // Any key (e.g., spacebar) should resolve + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + app = model.(App) + if cmd == nil { + t.Fatal("expected response command") + } + cmd() + + select { + case resp := <-responseCh: + if resp.SelectedKey != "any" { + t.Fatalf("expected any key, got %q", resp.SelectedKey) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for response on channel") + } + + if app.inputPrompt.Visible() { + t.Fatal("expected input prompt to be hidden after response") + } +} + +func TestAppPendingInputOptionCOverridesClipboardShortcut(t *testing.T) { + t.Parallel() + + app := NewApp("dev", "", "", nil) + responseCh := make(chan output.InputResponse, 1) + + model, _ := app.Update(output.AuthEvent{URL: "https://example.com"}) + app = model.(App) + + model, _ = app.Update(output.UserInputRequestEvent{ + Prompt: "Choose option", + Options: []output.InputOption{ + {Key: "c", Label: "Continue"}, + {Key: "x", Label: "Cancel"}, + }, + ResponseCh: responseCh, + }) + app = model.(App) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + app = model.(App) + if cmd == nil { + t.Fatal("expected pending-input response command") + } + msg := cmd() + if msg != nil { + t.Fatalf("expected pending-input command to return nil tea.Msg, got %#v", msg) + } + + select { + case resp := <-responseCh: + if resp.SelectedKey != "c" { + t.Fatalf("expected c key, got %q", resp.SelectedKey) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for response on channel") + } + + if app.inputPrompt.Visible() { + t.Fatal("expected input prompt to be hidden after response") + } +} diff --git a/internal/ui/components/input_prompt.go b/internal/ui/components/input_prompt.go index 3f2eca8..5a8f44b 100644 --- a/internal/ui/components/input_prompt.go +++ b/internal/ui/components/input_prompt.go @@ -1,8 +1,6 @@ package components import ( - "strings" - "github.com/localstack/lstk/internal/output" "github.com/localstack/lstk/internal/ui/styles" ) @@ -37,16 +35,6 @@ func (p InputPrompt) View() string { if !p.visible { return "" } - text := p.prompt - switch len(p.options) { - case 1: - text += " (" + p.options[0].Label + ")" - default: - labels := make([]string, len(p.options)) - for i, opt := range p.options { - labels[i] = opt.Label - } - text += " [" + strings.Join(labels, "/") + "]" - } - return styles.Message.Render(text) + + return styles.SecondaryMessage.Render(output.FormatPrompt(p.prompt, p.options)) } diff --git a/internal/ui/components/spinner.go b/internal/ui/components/spinner.go index 6f35e3a..e0c17dc 100644 --- a/internal/ui/components/spinner.go +++ b/internal/ui/components/spinner.go @@ -63,6 +63,11 @@ func (s Spinner) HandleMinDurationElapsed() Spinner { return s } +func (s Spinner) SetText(text string) Spinner { + s.text = text + return s +} + func (s Spinner) Visible() bool { return s.visible } @@ -80,7 +85,7 @@ func (s Spinner) View() string { if !s.visible { return "" } - return s.model.View() + " " + styles.Secondary.Render(s.text) + return s.model.View() + styles.Secondary.Render(s.text) } func (s Spinner) Tick() tea.Cmd { diff --git a/internal/ui/run_login_test.go b/internal/ui/run_login_test.go index a69239c..9c42b22 100644 --- a/internal/ui/run_login_test.go +++ b/internal/ui/run_login_test.go @@ -113,13 +113,7 @@ func TestLoginFlow_DeviceFlowSuccess(t *testing.T) { }() teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Open browser now?")) - }, teatest.WithDuration(5*time.Second)) - - tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) - - teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Waiting for authentication")) + return bytes.Contains(bts, []byte("Waiting for authorization")) }, teatest.WithDuration(5*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) @@ -131,11 +125,12 @@ func TestLoginFlow_DeviceFlowSuccess(t *testing.T) { t.Fatal("timeout waiting for login") } + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Login successful")) + }, teatest.WithDuration(5*time.Second)) + tm.Send(tea.QuitMsg{}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) - - out := readOutput(tm.FinalOutput(t)) - assert.Contains(t, out, "Login successful") } func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) { @@ -170,13 +165,7 @@ func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) { }() teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Open browser now?")) - }, teatest.WithDuration(5*time.Second)) - - tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) - - teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { - return bytes.Contains(bts, []byte("Waiting for authentication")) + return bytes.Contains(bts, []byte("Waiting for authorization")) }, teatest.WithDuration(5*time.Second)) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) @@ -189,10 +178,62 @@ func TestLoginFlow_DeviceFlowFailure_NotConfirmed(t *testing.T) { t.Fatal("timeout waiting for login") } + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Authentication failed")) + }, teatest.WithDuration(5*time.Second)) + tm.Send(tea.QuitMsg{}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} + +func TestLoginFlow_DeviceFlowCancelWithCtrlC(t *testing.T) { + mockServer := createMockAPIServer(t, "", false) + defer mockServer.Close() + + t.Setenv("LSTK_API_ENDPOINT", mockServer.URL) + t.Setenv("LOCALSTACK_AUTH_TOKEN", "") + env.Init() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ctrl := gomock.NewController(t) + mockStorage := auth.NewMockAuthTokenStorage(ctrl) + mockStorage.EXPECT().GetAuthToken().Return("", errors.New("no token")) + + tm := teatest.NewTestModel(t, NewApp("test", "", "", cancel), teatest.WithInitialTermSize(120, 40)) + sender := testModelSender{tm: tm} + platformClient := api.NewPlatformClient() + + errCh := make(chan error, 1) + go func() { + a := auth.New(output.NewTUISink(sender), platformClient, mockStorage, true) + _, err := a.GetToken(ctx) + errCh <- err + if err != nil && !errors.Is(err, context.Canceled) { + tm.Send(runErrMsg{err: err}) + } else { + tm.Send(runDoneMsg{}) + } + }() + + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { + return bytes.Contains(bts, []byte("Waiting for authorization")) + }, teatest.WithDuration(5*time.Second)) + + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) + + select { + case err := <-errCh: + require.Error(t, err, "login should be canceled") + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for login") + } + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) out := readOutput(tm.FinalOutput(t)) - assert.Contains(t, out, "Authentication failed") + assert.NotContains(t, out, "Authentication failed") } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 6f3972d..792ffb7 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -18,8 +18,24 @@ var ( NimboLight = lipgloss.NewStyle(). Foreground(lipgloss.Color(NimboLightColor)) - Message = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) + Title = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("69")) + + Version = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + Message = lipgloss.NewStyle() + + SecondaryMessage = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) + + Highlight = lipgloss.NewStyle(). + Foreground(lipgloss.Color(NimboLightColor)) + + Link = lipgloss.NewStyle(). + Foreground(lipgloss.Color(NimboLightColor)). + Underline(true) // Message severity styles Success = lipgloss.NewStyle(). diff --git a/test/integration/login_test.go b/test/integration/login_test.go index 7a75257..8993128 100644 --- a/test/integration/login_test.go +++ b/test/integration/login_test.go @@ -107,17 +107,10 @@ func TestDeviceFlowSuccess(t *testing.T) { close(outputCh) }() - // Wait for browser prompt, then press Y to open browser + // Wait for the auth completion prompt, then press ENTER. require.Eventually(t, func() bool { - return bytes.Contains(output.Bytes(), []byte("Open browser now?")) - }, 10*time.Second, 100*time.Millisecond, "browser prompt should appear") - _, err = ptmx.Write([]byte("y")) - require.NoError(t, err) - - // Wait for ENTER prompt, then press ENTER to confirm auth is complete - require.Eventually(t, func() bool { - return bytes.Contains(output.Bytes(), []byte("Waiting for authentication")) - }, 10*time.Second, 100*time.Millisecond, "waiting prompt should appear") + return bytes.Contains(output.Bytes(), []byte("Press any key when complete")) + }, 10*time.Second, 100*time.Millisecond, "auth completion prompt should appear") _, err = ptmx.Write([]byte("\r")) require.NoError(t, err) @@ -127,9 +120,11 @@ func TestDeviceFlowSuccess(t *testing.T) { out := output.String() require.NoError(t, err, "login should succeed: %s", out) - assert.Contains(t, out, "Verification code:") + assert.Contains(t, out, "Your one-time code") assert.Contains(t, out, "TEST123") - assert.Contains(t, out, "Open browser now?") + assert.Contains(t, out, "Opening browser to login...") + assert.Contains(t, out, "/auth/request/test-auth-req-id") + assert.Contains(t, out, "Waiting for authorization") assert.Contains(t, out, "Checking if auth request is confirmed") assert.Contains(t, out, "Auth request confirmed") assert.Contains(t, out, "Fetching license token") @@ -169,17 +164,10 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { close(outputCh) }() - // Wait for browser prompt, then press Y to open browser + // Wait for the auth completion prompt, then press ENTER. require.Eventually(t, func() bool { - return bytes.Contains(output.Bytes(), []byte("Open browser now?")) - }, 10*time.Second, 100*time.Millisecond, "browser prompt should appear") - _, err = ptmx.Write([]byte("y")) - require.NoError(t, err) - - // Wait for ENTER prompt, then press ENTER to confirm auth is complete - require.Eventually(t, func() bool { - return bytes.Contains(output.Bytes(), []byte("Waiting for authentication")) - }, 10*time.Second, 100*time.Millisecond, "waiting prompt should appear") + return bytes.Contains(output.Bytes(), []byte("Press any key when complete")) + }, 10*time.Second, 100*time.Millisecond, "auth completion prompt should appear") _, err = ptmx.Write([]byte("\r")) require.NoError(t, err) @@ -189,9 +177,11 @@ func TestDeviceFlowFailure_RequestNotConfirmed(t *testing.T) { out := output.String() require.Error(t, err, "expected login to fail when request not confirmed") - assert.Contains(t, out, "Verification code:") - assert.Contains(t, out, "Open browser now?") - assert.Contains(t, out, "Waiting for authentication") + assert.Contains(t, out, "Your one-time code") + assert.Contains(t, out, "TEST123") + assert.Contains(t, out, "Opening browser to login...") + assert.Contains(t, out, "/auth/request/test-auth-req-id") + assert.Contains(t, out, "Waiting for authorization") assert.Contains(t, out, "Checking if auth request is confirmed") assert.Contains(t, out, "auth request not confirmed")