242 lines
11 KiB
Go
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, "Active 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, "Active 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)
|
|
}
|
|
}
|