Files
proxy/internal/tui/active_profile_picker_test.go
2026-08-30 15:49:04 +08:00

242 lines
11 KiB
Go

package tui
import (
"errors"
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
func pickerSnapshot(names ...string) app.Snapshot {
profiles := make([]profile.Profile, len(names))
for i, name := range names {
profiles[i] = profile.Profile{Name: name}
}
return app.Snapshot{
ActiveProfile: "Zulu",
Profiles: profiles,
Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled},
{ID: "docker", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Enabled},
},
}
}
func openProfilePicker(t *testing.T, model Model) Model {
t.Helper()
updated, command := model.Update(keyMessage("p"))
model = updated.(Model)
if command != nil {
// The Bubbles text input may ask Bubble Tea to start its cursor blink.
_ = command
}
if !strings.Contains(model.View(), "Switch Active Profile") {
t.Fatalf("dashboard action did not open the picker:\n%s", model.View())
}
return model
}
func TestCompleteModelPickerPinsActiveProfileSortsOthersAndFiltersByTyping(t *testing.T) {
module := &interactiveScriptModule{snapshot: pickerSnapshot("beta", "Zulu", "alpha", "qaGamma")}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = openProfilePicker(t, model)
view := model.View()
active := strings.Index(view, "> ★ Zulu — Active Profile")
alpha := strings.Index(view, " alpha")
beta := strings.Index(view, " beta")
gamma := strings.Index(view, " qaGamma")
if active < 0 || !(active < alpha && alpha < beta && beta < gamma) {
t.Fatalf("picker order is not Active-first then name-sorted:\n%s", view)
}
if strings.Contains(view, "new") || strings.Contains(view, "edit") || strings.Contains(view, "delete") {
t.Fatalf("routine picker conflated switching with Profile management:\n%s", view)
}
// q is searchable input while the modal is open, not the dashboard quit key.
model = updateKey(model, "q")
model = updateRunes(model, "ag")
view = model.View()
if !strings.Contains(view, "> qaGamma") || strings.Contains(view, " alpha") || strings.Contains(view, " beta") || strings.Contains(view, "Active Profile — Zulu") {
t.Fatalf("typed search did not filter and select the match:\n%s", view)
}
model = resize(model, 120, 36)
model = resize(model, 80, 24)
view = model.View()
if !strings.Contains(view, "qag") || !strings.Contains(view, "> qaGamma") {
t.Fatalf("resize lost picker search or selected Profile:\n%s", view)
}
}
func TestCompleteModelPickerScrollsManyProfilesAndEnterActivatesSelection(t *testing.T) {
names := make([]string, 30)
for i := range names {
names[i] = fmt.Sprintf("profile-%02d", i)
}
names = append(names, "Zulu")
updates := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: pickerSnapshot(names...), streams: []chan app.OperationUpdate{updates}}
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 60, 16))
for range 20 {
model = updateKey(model, "down")
}
if view := model.View(); !strings.Contains(view, "> profile-19") {
t.Fatalf("selection did not scroll into the short picker viewport:\n%s", view)
}
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command == nil || strings.Contains(model.View(), "Switch Active Profile") {
t.Fatalf("Enter did not close the picker and start activation:\n%s", model.View())
}
if len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "profile-19" {
t.Fatalf("Enter dispatched %#v", module.intents)
}
}
func TestCompleteModelPickerEscapeAndEmptyStatesDoNotMutate(t *testing.T) {
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "alpha")}
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 80, 24))
model = updateKey(model, "esc")
if strings.Contains(model.View(), "Switch Active Profile") || len(module.intents) != 0 {
t.Fatalf("Escape did not cancel without mutation: intents=%#v\n%s", module.intents, model.View())
}
empty := &interactiveScriptModule{snapshot: app.Snapshot{}}
model = openProfilePicker(t, resizedModel(t, empty, themeModeNoColor, 80, 24))
view := model.View()
if !strings.Contains(view, "No Proxy Profiles are available") || !strings.Contains(view, "Escape") || !strings.Contains(view, "Profile management") {
t.Fatalf("empty picker lacks a distinct next action:\n%s", view)
}
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command != nil || len(empty.intents) != 0 || !strings.Contains(model.View(), "No Proxy Profiles are available") {
t.Fatalf("Enter mutated the empty picker: intents=%#v", empty.intents)
}
noMatch := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "alpha")}
model = openProfilePicker(t, resizedModel(t, noMatch, themeModeNoColor, 80, 24))
model = updateRunes(model, "missing")
view = model.View()
if !strings.Contains(view, `No Proxy Profiles match "missing"`) || !strings.Contains(view, "Edit the search") {
t.Fatalf("no-match picker lacks a search-specific next action:\n%s", view)
}
}
func TestCompleteModelPickerActivationUsesProgressAndSafeDockerConfirmation(t *testing.T) {
confirmation := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "new"), streams: []chan app.OperationUpdate{confirmation}}
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 120, 36))
model = updateKey(model, "down")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
confirmation <- app.ConfirmationOperationUpdate(module.snapshot, app.Confirmation{
Title: "Confirm Active Profile change", Summary: "Docker will restart and running work may be disrupted.",
ConfirmLabel: "Change Profile", CancelLabel: "Cancel", HighImpact: true,
})
close(confirmation)
model, _ = applyOperationCommand(t, model, command)
view := model.View()
if !strings.Contains(view, "Confirm Active Profile change") || !strings.Contains(view, "n/q/Esc: cancel") || !strings.Contains(view, "> Cancel") {
t.Fatalf("activation did not use safe-default confirmation:\n%s", view)
}
updated, next := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if next != nil || len(module.intents) != 1 || strings.Contains(model.View(), "Confirm Active Profile change") || model.snapshot.ActiveProfile != "Zulu" {
t.Fatalf("confirmation cancellation was not mutation-free: intents=%#v\n%s", module.intents, model.View())
}
}
func TestCompleteModelPickerConfirmedActivationContinuesThroughUnifiedOperation(t *testing.T) {
confirmation := make(chan app.OperationUpdate, 1)
completion := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "new"), streams: []chan app.OperationUpdate{confirmation, completion}}
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 120, 36))
model = updateKey(model, "down")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
confirmation <- app.ConfirmationOperationUpdate(module.snapshot, app.Confirmation{
Title: "Confirm Active Profile change", Summary: "Docker will restart.",
ConfirmLabel: "Change Profile", CancelLabel: "Cancel", HighImpact: true,
})
close(confirmation)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(keyMessage("y"))
model = updated.(Model)
if command == nil || len(module.intents) != 2 || !module.intents[1].Confirm || module.intents[1].Name != "new" {
t.Fatalf("confirmation did not continue the selected activation: intents=%#v", module.intents)
}
completed := pickerSnapshot("Zulu", "new")
completed.ActiveProfile = "new"
completion <- app.SucceededOperationUpdate(completed, app.OperationStatus{Summary: `Activated Profile "new"`}, nil)
close(completion)
model, _ = applyOperationCommand(t, model, command)
if view := model.View(); !strings.Contains(view, "Profile: new") || !strings.Contains(view, `Activated Profile "new"`) {
t.Fatalf("confirmed activation did not finish on the dashboard:\n%s", view)
}
}
func TestCompleteModelPickerPartialFailureKeepsProgressClosesPickerAndFocusesFailure(t *testing.T) {
updates := make(chan app.OperationUpdate, 2)
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "new"), streams: []chan app.OperationUpdate{updates}}
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 120, 36))
model = updateKey(model, "down")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
running := pickerSnapshot("Zulu", "new")
running.ActiveProfile = "new"
updates <- app.RunningOperationUpdate(running,
app.OperationStatus{Summary: "Updating Shell Target", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
model, command = applyOperationCommand(t, model, command)
if view := model.View(); strings.Contains(view, "Switch Active Profile") || !strings.Contains(view, "Applying") || !strings.Contains(view, "Updating Shell Target") {
t.Fatalf("activation progress was not shown on the dashboard:\n%s", view)
}
failed := running
failed.Targets[0].State = target.Enabled
failed.Targets[1].State = target.Drifted
updates <- app.FailedOperationUpdate(failed,
app.OperationStatus{Summary: "Activated Profile new with 1 Target update failure", TargetID: "docker"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}, {TargetID: "docker", Phase: app.TargetProgressFailed}},
"docker", errors.New("Docker update failed"))
close(updates)
model, _ = applyOperationCommand(t, model, command)
view := model.View()
if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Drifted ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Profile: new") {
t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view)
}
}
func TestCompleteModelPickerDefensivelyRedactsNamesAndOperationText(t *testing.T) {
const secret = "picker-secret"
credential := "https://alice:" + secret + "@proxy.example:8443"
updates := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", credential), streams: []chan app.OperationUpdate{updates}}
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 80, 24))
if view := model.View(); strings.Contains(view, secret) || !strings.Contains(view, "REDACTED") {
t.Fatalf("picker exposed a Profile name credential:\n%s", view)
}
model = updateKey(model, "down")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
updates <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "failed " + credential}, nil, "", errors.New("detail "+credential))
close(updates)
model, _ = applyOperationCommand(t, model, command)
model = updateKey(model, "e")
if view := model.View(); strings.Contains(view, secret) || !strings.Contains(view, "REDACTED") {
t.Fatalf("picker operation text exposed a credential:\n%s", view)
}
}