375 lines
18 KiB
Go
375 lines
18 KiB
Go
package tui
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/profile"
|
|
"proxyctl/internal/target"
|
|
)
|
|
|
|
type targetModule struct {
|
|
snapshot app.Snapshot
|
|
result app.Result
|
|
intents []app.Intent
|
|
}
|
|
|
|
func (m *targetModule) Snapshot() app.Snapshot { return m.snapshot }
|
|
func (m *targetModule) Dispatch(intent app.Intent) app.Result {
|
|
m.intents = append(m.intents, intent)
|
|
m.snapshot = m.result.Snapshot
|
|
return m.result
|
|
}
|
|
func (m *targetModule) StartOperation(intent app.Intent) app.OperationStream {
|
|
initial := m.Snapshot()
|
|
result := m.Dispatch(intent)
|
|
updates := make(chan app.OperationUpdate, 1)
|
|
status := app.OperationStatus{Summary: result.Notice, TargetID: intent.TargetID}
|
|
switch {
|
|
case result.ConfirmationRequest != nil:
|
|
updates <- app.ConfirmationOperationUpdate(result.Snapshot, *result.ConfirmationRequest)
|
|
case result.Err != nil:
|
|
updates <- app.FailedOperationUpdate(result.Snapshot, app.OperationStatus{Summary: "Operation failed", TargetID: intent.TargetID}, nil, intent.TargetID, result.Err)
|
|
default:
|
|
updates <- app.SucceededOperationUpdate(result.Snapshot, status, nil)
|
|
}
|
|
close(updates)
|
|
return app.OperationStream{Snapshot: initial, Updates: updates}
|
|
}
|
|
|
|
func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing.T) {
|
|
module := &targetModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{
|
|
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.External, Evidence: "existing proxy"},
|
|
{ID: "apt", Description: target.Description{Name: "APT"}, Available: false},
|
|
}}}
|
|
module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches"}}}, Notice: "Shell Target is now Enabled"}
|
|
model := NewModel(module)
|
|
view := model.View()
|
|
if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") {
|
|
t.Fatalf("Target panel:\n%s", view)
|
|
}
|
|
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
if command == nil || !strings.Contains(model.View(), "Applying") || len(module.intents) != 1 {
|
|
t.Fatalf("operation did not expose applying state before effect:\n%s", model.View())
|
|
}
|
|
message := command()
|
|
updated, _ = model.Update(message)
|
|
model = updated.(Model)
|
|
if len(module.intents) != 1 || module.intents[0].Kind != app.SetTargetDesired || !module.intents[0].Enabled || !strings.Contains(model.View(), "Enabled") {
|
|
t.Fatalf("completed toggle = %#v\n%s", module.intents, model.View())
|
|
}
|
|
}
|
|
|
|
func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T) {
|
|
initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Disabled}}}
|
|
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, ConfirmationRequest: &app.Confirmation{Title: "Confirm Docker restart", Summary: "Docker will restart immediately and may disrupt running work.", ConfirmLabel: "Continue", CancelLabel: "Cancel"}}}
|
|
model := NewModel(module)
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
updated, _ = model.Update(command())
|
|
model = updated.(Model)
|
|
if !strings.Contains(model.View(), "restart immediately") || len(module.intents) != 1 || module.intents[0].Confirm {
|
|
t.Fatalf("warning = intents %#v\n%s", module.intents, model.View())
|
|
}
|
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
|
model = updated.(Model)
|
|
if command != nil || len(module.intents) != 1 || strings.Contains(model.View(), "restart immediately") {
|
|
t.Fatalf("cancel mutated = intents %#v\n%s", module.intents, model.View())
|
|
}
|
|
|
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
updated, _ = model.Update(command())
|
|
model = updated.(Model)
|
|
module.result = app.Result{
|
|
Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Enabled}}},
|
|
Notice: "Docker daemon Target is now Enabled",
|
|
}
|
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
|
model = updated.(Model)
|
|
if command == nil {
|
|
t.Fatal("confirmation did not start operation")
|
|
}
|
|
_ = command()
|
|
if len(module.intents) != 3 || !module.intents[2].Confirm {
|
|
t.Fatalf("confirmed intents = %#v", module.intents)
|
|
}
|
|
}
|
|
|
|
func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) {
|
|
module := &targetModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
|
|
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "summary", Details: "affects future Shell commands; does not affect daemons; owns a generated fragment; user permission; reload side effect"}, Available: true, State: target.Disabled, Evidence: "integration installed"},
|
|
{ID: "apt", Description: target.Description{Name: "APT", Summary: "persistent package proxy", Details: "owns an overlay and requires system write permission"}, Available: false, AvailabilityEvidence: "Debian APT tool apt-config was not detected"},
|
|
}}}
|
|
model := NewModel(module)
|
|
view := model.View()
|
|
if strings.Contains(view, "APT") || !strings.Contains(view, "summary") || !strings.Contains(view, "integration installed") {
|
|
t.Fatalf("main Target panel:\n%s", view)
|
|
}
|
|
model = updateKey(model, "?")
|
|
view = model.View()
|
|
if !strings.Contains(view, "Shell") || !strings.Contains(view, "APT") || !strings.Contains(view, "Not detected: Debian APT tool apt-config was not detected") || !strings.Contains(view, "owns an overlay") {
|
|
t.Fatalf("Target help:\n%s", view)
|
|
}
|
|
model = updateKey(model, "esc")
|
|
if !strings.Contains(model.View(), "Operations Console") {
|
|
t.Fatalf("Escape did not return from help:\n%s", model.View())
|
|
}
|
|
}
|
|
|
|
func TestRefreshKeyRunsAsynchronouslyAndRendersRefreshingThenCompleted(t *testing.T) {
|
|
module := &targetModule{snapshot: app.Snapshot{}, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "apt", Description: target.Description{Name: "APT"}, Available: true, State: target.External}}}, Notice: "Refreshed Target availability and state"}}
|
|
model := NewModel(module)
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
|
|
model = updated.(Model)
|
|
if command == nil || len(module.intents) != 1 || !strings.Contains(model.View(), "Checking Target availability and state") {
|
|
t.Fatalf("refresh did not start asynchronously: intents %#v\n%s", module.intents, model.View())
|
|
}
|
|
message := command()
|
|
if len(module.intents) != 1 || module.intents[0].Kind != app.RefreshTargets {
|
|
t.Fatalf("refresh command intents = %#v", module.intents)
|
|
}
|
|
updated, command = model.Update(message)
|
|
model = updated.(Model)
|
|
if command != nil || !strings.Contains(model.View(), "External") || !strings.Contains(model.View(), "Refreshed Target availability and state") {
|
|
t.Fatalf("refresh completion:\n%s", model.View())
|
|
}
|
|
}
|
|
|
|
func TestBusyProfileTransitionBlocksPageEntryAndEveryNewOperation(t *testing.T) {
|
|
initial := app.Snapshot{
|
|
ActiveProfile: "old",
|
|
Profiles: []profile.Profile{{Name: "new"}},
|
|
Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}},
|
|
}
|
|
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: make(chan app.OperationUpdate)}
|
|
model := NewModel(module)
|
|
command := model.startOperation(app.Intent{Kind: app.ActivateProfile, Name: "new"})
|
|
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile {
|
|
t.Fatalf("Profile transition did not start exactly once: command=%v intents=%#v", command != nil, module.intents)
|
|
}
|
|
|
|
for _, key := range []string{"p", "r", " "} {
|
|
updated, blocked := model.Update(keyMessage(key))
|
|
model = updated.(Model)
|
|
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
|
|
t.Fatalf("busy activation accepted %q: page=%v command=%v intents=%#v", key, model.page, blocked != nil, module.intents)
|
|
}
|
|
}
|
|
updated, quit := model.Update(keyMessage("q"))
|
|
model = updated.(Model)
|
|
if quit != nil || !model.operation.quitRequested || len(module.intents) != 1 {
|
|
t.Fatalf("busy activation did not defer quit: command=%v requested=%v intents=%#v", quit != nil, model.operation.quitRequested, module.intents)
|
|
}
|
|
|
|
// Even if a Profiles-page activation key is delivered while the transition
|
|
// is active, the single-operation guard must reject a second activation.
|
|
model.page = profilesPage
|
|
updated, blocked := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
model = updated.(Model)
|
|
if blocked != nil || len(module.intents) != 1 {
|
|
t.Fatalf("busy activation started a second activation: command=%v intents=%#v", blocked != nil, module.intents)
|
|
}
|
|
}
|
|
|
|
func TestBusyRefreshBlocksTogglePageEntryRefreshAndActivation(t *testing.T) {
|
|
initial := app.Snapshot{
|
|
Profiles: []profile.Profile{{Name: "office"}},
|
|
Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}},
|
|
}
|
|
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial}}
|
|
model := NewModel(module)
|
|
updated, refreshCommand := model.Update(keyMessage("r"))
|
|
model = updated.(Model)
|
|
if refreshCommand == nil {
|
|
t.Fatal("refresh did not start")
|
|
}
|
|
|
|
for _, key := range []string{" ", "p", "r"} {
|
|
updated, blocked := model.Update(keyMessage(key))
|
|
model = updated.(Model)
|
|
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
|
|
t.Fatalf("busy refresh accepted %q: page=%v command=%v intents=%#v", key, model.page, blocked != nil, module.intents)
|
|
}
|
|
}
|
|
updated, quit := model.Update(keyMessage("q"))
|
|
model = updated.(Model)
|
|
if quit != nil || !model.operation.quitRequested || len(module.intents) != 1 {
|
|
t.Fatalf("busy refresh did not defer quit: command=%v intents=%#v", quit != nil, module.intents)
|
|
}
|
|
}
|
|
|
|
func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) {
|
|
initial := app.Snapshot{Targets: []app.TargetSnapshot{
|
|
{ID: "one", Description: target.Description{Name: "One"}, Available: true},
|
|
{ID: "two", Description: target.Description{Name: "Two"}, Available: true},
|
|
{ID: "three", Description: target.Description{Name: "Three"}, Available: true},
|
|
{ID: "four", Description: target.Description{Name: "Four"}, Available: true},
|
|
}}
|
|
refreshed := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "one", Description: target.Description{Name: "One"}, Available: true}}}
|
|
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: refreshed}}
|
|
model := NewModel(module)
|
|
model.selectedTargetID = "four"
|
|
updated, command := model.Update(keyMessage("r"))
|
|
model = updated.(Model)
|
|
updated, _ = model.Update(command())
|
|
model = updated.(Model)
|
|
if model.selectedTargetID != "one" || !strings.Contains(model.View(), "> ? Unknown One") {
|
|
t.Fatalf("selection after multi-Target reduction = %q\n%s", model.selectedTargetID, model.View())
|
|
}
|
|
|
|
module.result = app.Result{Snapshot: app.Snapshot{}}
|
|
updated, command = model.Update(keyMessage("r"))
|
|
model = updated.(Model)
|
|
updated, _ = model.Update(command())
|
|
model = updated.(Model)
|
|
if model.selectedTargetID != "" {
|
|
t.Fatalf("selection with no Available Targets = %q, want empty", model.selectedTargetID)
|
|
}
|
|
}
|
|
|
|
func keyMessage(key string) tea.KeyMsg {
|
|
if key == " " {
|
|
return tea.KeyMsg{Type: tea.KeySpace}
|
|
}
|
|
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
|
}
|
|
|
|
func TestTargetPanelNavigationKeysAndQuitCombinations(t *testing.T) {
|
|
module := &targetModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
|
|
{ID: "first", Description: target.Description{Name: "First", Summary: "first summary"}, Available: true},
|
|
{ID: "second", Description: target.Description{Name: "Second", Summary: "second summary"}, Available: true},
|
|
}}}
|
|
for _, down := range []tea.KeyMsg{{Type: tea.KeyDown}, {Type: tea.KeyRunes, Runes: []rune{'j'}}} {
|
|
model := NewModel(module)
|
|
updated, _ := model.Update(down)
|
|
model = updated.(Model)
|
|
if !strings.Contains(model.View(), "> ? Unknown Second") {
|
|
t.Fatalf("down key %q did not select second Target:\n%s", down.String(), model.View())
|
|
}
|
|
for _, up := range []tea.KeyMsg{{Type: tea.KeyUp}, {Type: tea.KeyRunes, Runes: []rune{'k'}}} {
|
|
updated, _ = model.Update(up)
|
|
if !strings.Contains(updated.(Model).View(), "> ? Unknown First") {
|
|
t.Fatalf("up key %q did not select first Target:\n%s", up.String(), updated.(Model).View())
|
|
}
|
|
}
|
|
}
|
|
_, command := NewModel(module).Update(keyMessage("q"))
|
|
if command == nil {
|
|
t.Fatal("dashboard q returned no command")
|
|
}
|
|
if _, ok := command().(tea.QuitMsg); !ok {
|
|
t.Fatal("dashboard q did not return tea.QuitMsg")
|
|
}
|
|
_, command = NewModel(module).Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
if command != nil {
|
|
t.Fatal("dashboard Escape must remain cancel/back rather than exit")
|
|
}
|
|
}
|
|
|
|
func TestQAndEscapeReturnFromNestedPages(t *testing.T) {
|
|
module := &targetModule{}
|
|
for _, key := range []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune{'q'}}, {Type: tea.KeyEsc}} {
|
|
model := updateKey(NewModel(module), "?")
|
|
updated, command := model.Update(key)
|
|
if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") {
|
|
t.Fatalf("help key %q did not return to Operations Console", key.String())
|
|
}
|
|
model = updateKey(NewModel(module), "p")
|
|
updated, command = model.Update(key)
|
|
if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") {
|
|
t.Fatalf("Profile key %q did not return to Target panel", key.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
type progressiveTargetModule struct {
|
|
targetModule
|
|
updates chan app.OperationUpdate
|
|
}
|
|
|
|
func (m *progressiveTargetModule) StartOperation(intent app.Intent) app.OperationStream {
|
|
m.intents = append(m.intents, intent)
|
|
return app.OperationStream{Snapshot: m.snapshot, Updates: m.updates}
|
|
}
|
|
|
|
func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) {
|
|
initial := app.Snapshot{ActiveProfile: "old", Profiles: []profile.Profile{{Name: "new"}}, Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
|
|
updates := make(chan app.OperationUpdate, 3)
|
|
updates <- app.RunningOperationUpdate(app.Snapshot{ActiveProfile: "new", Targets: initial.Targets}, app.OperationStatus{Summary: "Updating Shell Target", TargetID: "shell"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
|
|
completedSnapshot := app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
|
|
updates <- app.RunningOperationUpdate(completedSnapshot, app.OperationStatus{Summary: "Shell Target update completed", TargetID: "shell"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded, State: target.Enabled}})
|
|
updates <- app.SucceededOperationUpdate(completedSnapshot, app.OperationStatus{Summary: "Activated Profile new"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded, State: target.Enabled}})
|
|
close(updates)
|
|
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
|
|
model := NewModel(module)
|
|
command := model.startOperation(app.Intent{Kind: app.ActivateProfile, Name: "new"})
|
|
if command == nil {
|
|
t.Fatal("Profile transition did not start progressive operation")
|
|
}
|
|
updated, command := model.Update(command())
|
|
model = updated.(Model)
|
|
if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Active Profile: new") {
|
|
t.Fatalf("applying progress:\n%s", model.View())
|
|
}
|
|
updated, command = model.Update(command())
|
|
model = updated.(Model)
|
|
if command == nil || !strings.Contains(model.View(), "Enabled") || !strings.Contains(model.View(), "update completed") {
|
|
t.Fatalf("Target completion:\n%s", model.View())
|
|
}
|
|
updated, _ = model.Update(command())
|
|
model = updated.(Model)
|
|
if !strings.Contains(model.View(), "Activated Profile new") {
|
|
t.Fatalf("final transition:\n%s", model.View())
|
|
}
|
|
}
|
|
|
|
func TestTargetPanelRendersEveryTargetState(t *testing.T) {
|
|
states := []target.State{target.Enabled, target.Disabled, target.External, target.Drifted}
|
|
var targets []app.TargetSnapshot
|
|
for _, state := range states {
|
|
targets = append(targets, app.TargetSnapshot{ID: string(state), Description: target.Description{Name: string(state)}, Available: true, State: state})
|
|
}
|
|
view := NewModel(&targetModule{snapshot: app.Snapshot{Targets: targets}}).View()
|
|
for _, state := range states {
|
|
if !strings.Contains(view, string(state)) {
|
|
t.Fatalf("missing state %s:\n%s", state, view)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDriftedTargetWithoutActiveProfileTogglesTowardDisabled(t *testing.T) {
|
|
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}}
|
|
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}}}
|
|
model := NewModel(module)
|
|
_, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
if command == nil {
|
|
t.Fatal("Drifted Target did not start disable")
|
|
}
|
|
_ = command()
|
|
if len(module.intents) != 1 || module.intents[0].Enabled {
|
|
t.Fatalf("Drifted Target intent = %#v", module.intents)
|
|
}
|
|
}
|
|
|
|
func TestTargetFailurePreservesTruthfulStateAndRendersRedactedError(t *testing.T) {
|
|
initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
|
|
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("permission denied for alice:REDACTED@proxy")}}
|
|
model := NewModel(module)
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
updated, _ = model.Update(command())
|
|
model = updated.(Model)
|
|
model = updateKey(model, "e")
|
|
view := model.View()
|
|
if !strings.Contains(view, "Enabled") || !strings.Contains(view, "Current error:") || !strings.Contains(view, "REDACTED") {
|
|
t.Fatalf("failed operation rendering:\n%s", view)
|
|
}
|
|
}
|