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) } }