Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions internal/container/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -1335,16 +1335,21 @@ func isDefinitiveLicenseRejection(status int) bool {
// ESC declines. Ctrl+C would do too, but it also cancels the root context, and
// the ErrorEvent that the decline renders then races the TUI's own quit — so the
// manual recovery steps sometimes never reach the terminal (DEVX-1045). An
// advertised decline key keeps that path deterministic.
// advertised decline key keeps that path deterministic. The choices render
// vertically so both keys read as selectable actions rather than a hint tacked
// onto the end of the sentence; the prompt therefore states the reason and
// leaves the two actions to the labels, which keeps it one wrapped statement
// instead of a statement whose trailing question dangles at the wrap point.
func promptRelogin(ctx context.Context, sink output.Sink, licErr *api.LicenseError) bool {
responseCh := make(chan output.InputResponse, 1)
sink.Emit(output.UserInputRequestEvent{
Prompt: fmt.Sprintf("License validation failed: %s. Log in again to refresh your credentials?", licErr.Message),
Prompt: fmt.Sprintf("License validation failed: %s.", licErr.Message),
Options: []output.InputOption{
{Key: "enter", Label: "ENTER to log in again"},
{Key: "esc", Label: "ESC to exit"},
{Key: "r", Label: "[R] Re-authenticate"},
{Key: "esc", Label: "[ESC] Exit"},
},
ResponseCh: responseCh,
Vertical: true,
})
select {
case resp := <-responseCh:
Expand Down
14 changes: 7 additions & 7 deletions internal/container/start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1698,7 +1698,7 @@ func TestPromptRelogin_FoldsReasonIntoThePromptWithoutASeparateWarning(t *testin
req, ok := events[0].(output.UserInputRequestEvent)
require.True(t, ok, "the only event emitted must be the prompt itself")
assert.Contains(t, req.Prompt, licErr.Message, "the prompt must explain why the user is being asked to log in again")
assert.Contains(t, req.Prompt, "Log in again to refresh your credentials?")
assert.Equal(t, "[R] Re-authenticate", req.Options[0].Label, "the recovery action belongs to the choice, not the prompt sentence")
}

// TestPromptRelogin_OffersAnAdvertisedDeclineKey covers DEVX-1045: Ctrl+C was the
Expand All @@ -1712,7 +1712,7 @@ func TestPromptRelogin_OffersAnAdvertisedDeclineKey(t *testing.T) {
response output.InputResponse
accepted bool
}{
{name: "enter accepts", response: output.InputResponse{SelectedKey: "enter"}, accepted: true},
{name: "r accepts", response: output.InputResponse{SelectedKey: "r"}, accepted: true},
{name: "esc declines", response: output.InputResponse{SelectedKey: "esc"}, accepted: false},
{name: "cancel declines", response: output.InputResponse{Cancelled: true}, accepted: false},
} {
Expand All @@ -1728,11 +1728,11 @@ func TestPromptRelogin_OffersAnAdvertisedDeclineKey(t *testing.T) {
accepted := promptRelogin(context.Background(), sink, licErr)

assert.Equal(t, tc.accepted, accepted)
keys := make([]string, 0, len(req.Options))
for _, opt := range req.Options {
keys = append(keys, opt.Key)
}
assert.Equal(t, []string{"enter", "esc"}, keys, "both the accept and the decline key must be advertised")
assert.True(t, req.Vertical, "the choices must render as vertical, selectable actions")
assert.Equal(t, []output.InputOption{
{Key: "r", Label: "[R] Re-authenticate"},
{Key: "esc", Label: "[ESC] Exit"},
}, req.Options, "both the accept and the decline key must be advertised, shortcut first")
})
}
}
85 changes: 85 additions & 0 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,91 @@ func TestAppEnterSelectsHighlightedVerticalOption(t *testing.T) {
}
}

// TestAppEscResolvesVerticalDeclineOption guards the license re-login prompt's
// decline path (DEVX-1045): the vertical key handler claims Enter for the
// highlighted row, so a direct ESC press must still fall through to its option
// rather than being swallowed.
func TestAppEscResolvesVerticalDeclineOption(t *testing.T) {
t.Parallel()

app := NewApp("dev", "", "", nil)
responseCh := make(chan output.InputResponse, 1)

model, _ := app.Update(output.UserInputRequestEvent{
Prompt: "License validation failed: invalid, inactive, or expired authentication token or subscription.",
Options: []output.InputOption{
{Key: "r", Label: "[R] Re-authenticate"},
{Key: "esc", Label: "[ESC] Exit"},
},
ResponseCh: responseCh,
Vertical: true,
})
app = model.(App)

model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEscape})
app = model.(App)
if cmd == nil {
t.Fatal("expected response command when esc is pressed on a vertical prompt")
}
cmd()

select {
case resp := <-responseCh:
if resp.SelectedKey != "esc" {
t.Fatalf("expected esc key, got %q", resp.SelectedKey)
}
if resp.Cancelled {
t.Fatal("expected esc to decline through its option, not as a cancellation")
}
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 TestAppReloginShortcutIgnoresVerticalSelection(t *testing.T) {
t.Parallel()

app := NewApp("dev", "", "", nil)
responseCh := make(chan output.InputResponse, 1)

model, _ := app.Update(output.UserInputRequestEvent{
Prompt: "License validation failed: invalid, inactive, or expired authentication token or subscription.",
Options: []output.InputOption{
{Key: "r", Label: "[R] Re-authenticate"},
{Key: "esc", Label: "[ESC] Exit"},
},
ResponseCh: responseCh,
Vertical: true,
})
app = model.(App)

model, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
app = model.(App)
model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
app = model.(App)
if cmd == nil {
t.Fatal("expected response command when r is pressed on a vertical prompt")
}
cmd()

select {
case resp := <-responseCh:
if resp.SelectedKey != "r" {
t.Fatalf("expected r 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 TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) {
t.Parallel()

Expand Down
34 changes: 34 additions & 0 deletions internal/ui/components/input_prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,37 @@ func TestInputPromptViewSlowStartChoicesAreScannable(t *testing.T) {
}
}
}

// TestInputPromptViewReloginChoicesAreScannable covers the license re-login
// prompt: its question is long enough to wrap, so flattening the two choices
// into a trailing hint made them read as prose. They belong on their own lines
// below the wrapped question, shortcut first.
func TestInputPromptViewReloginChoicesAreScannable(t *testing.T) {
t.Parallel()

const width = 40
question := "License validation failed: invalid, inactive, or expired authentication token or subscription."
p := NewInputPrompt().Show(question, []output.InputOption{
{Key: "r", Label: "[R] Re-authenticate"},
{Key: "esc", Label: "[ESC] Exit"},
}, true)

view := p.View(width)
lines := strings.Split(strings.TrimRight(view, "\n"), "\n")
if len(lines) < 3 {
t.Fatalf("expected a wrapped question and two vertical choices, got:\n%s", view)
}
if choices := lines[len(lines)-2:]; !strings.Contains(choices[0], "[R] Re-authenticate") ||
!strings.Contains(choices[1], "[ESC] Exit") {
t.Fatalf("expected each choice on its own trailing line, got:\n%s", view)
}
for _, line := range lines {
if w := lipgloss.Width(line); w > width {
t.Errorf("line exceeds width %d (%d): %q", width, w, line)
}
}
flattened := strings.Join(strings.Fields(view), " ")
if !strings.Contains(flattened, strings.Join(strings.Fields(question), " ")) {
t.Errorf("expected the whole question to survive wrapping, got:\n%s", view)
}
}
10 changes: 5 additions & 5 deletions test/integration/license_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,10 @@ func TestLicenseRejectionOffersReloginAndRetries(t *testing.T) {

p := startLstkInPTY(t, ctx, environ, "start", "--config", configFile)

// The stale token is rejected; the re-login prompt appears. Press ENTER.
// The wait covers a cold image pull on CI runners.
p.waitForOutputTimeout("Log in again", 3*time.Minute, "the re-login prompt should appear after the license rejection")
p.write("\r")
// The stale token is rejected; the re-login prompt appears. Press R, the
// shortcut it advertises. The wait covers a cold image pull on CI runners.
p.waitForOutputTimeout("[R] Re-authenticate", 3*time.Minute, "the re-login prompt should appear after the license rejection")
p.write("r")

// The login flow runs; confirm it once the completion prompt appears.
p.waitForOutputTimeout("key when complete", 30*time.Second, "the login completion prompt should appear")
Expand Down Expand Up @@ -287,7 +287,7 @@ func TestLicenseRejectionEscDeclineShowsManualSteps(t *testing.T) {
}
})

p.waitForOutputTimeout("ESC to exit", 60*time.Second, "the re-login prompt must be on screen, advertising the decline key")
p.waitForOutputTimeout("[ESC] Exit", 60*time.Second, "the re-login prompt must be on screen, advertising the decline key")
p.write("\x1b")

out, err := p.wait()
Expand Down
Loading