259 lines
11 KiB
Go
259 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/profile"
|
|
"proxyctl/internal/target"
|
|
)
|
|
|
|
type interactiveScriptModule struct {
|
|
snapshot app.Snapshot
|
|
streams []chan app.OperationUpdate
|
|
intents []app.Intent
|
|
}
|
|
|
|
func (m *interactiveScriptModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) }
|
|
func (m *interactiveScriptModule) Dispatch(intent app.Intent) app.Result {
|
|
m.intents = append(m.intents, intent)
|
|
return app.Result{Snapshot: m.snapshot}
|
|
}
|
|
func (m *interactiveScriptModule) StartOperation(intent app.Intent) app.OperationStream {
|
|
m.intents = append(m.intents, intent)
|
|
stream := m.streams[0]
|
|
m.streams = m.streams[1:]
|
|
return app.OperationStream{Snapshot: withTestTargetActions(m.snapshot), Updates: stream}
|
|
}
|
|
|
|
func operationSnapshot() app.Snapshot {
|
|
return withTestTargetActions(app.Snapshot{
|
|
ActiveProfile: "office",
|
|
Profiles: []profile.Profile{{Name: "office"}, {Name: "remote"}},
|
|
Targets: []app.TargetSnapshot{
|
|
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell details"}, Available: true, State: target.Disabled},
|
|
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT details"}, Available: true, State: target.Enabled},
|
|
},
|
|
})
|
|
}
|
|
|
|
func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) {
|
|
t.Helper()
|
|
if command == nil {
|
|
t.Fatal("operation command is nil")
|
|
}
|
|
updated, next := model.Update(command())
|
|
return updated.(Model), next
|
|
}
|
|
|
|
func TestCompleteModelKeepsDashboardAndHelpBrowsableWhileMutationIsSerialized(t *testing.T) {
|
|
updates := make(chan app.OperationUpdate, 2)
|
|
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
updates <- app.RunningOperationUpdate(module.snapshot,
|
|
app.OperationStatus{Summary: "Writing Shell configuration", TargetID: "shell"},
|
|
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
|
|
model, command = applyOperationCommand(t, model, command)
|
|
assertUsableFrame(t, model.View(), 120, 36, "Applying", "Writing Shell configuration")
|
|
|
|
model = updateKey(model, "down")
|
|
if model.selectedTargetID != "apt" {
|
|
t.Fatalf("selection while busy = %q, want apt", model.selectedTargetID)
|
|
}
|
|
model = updateKey(model, "tab")
|
|
model = updateKey(model, "down")
|
|
model = updateKey(model, "?")
|
|
if model.page != helpPage || !strings.Contains(model.View(), "proxyctl Help") {
|
|
t.Fatalf("Help was not reachable while mutation ran:\n%s", model.View())
|
|
}
|
|
model = updateKey(model, "q")
|
|
if model.page != mainPage {
|
|
t.Fatal("q did not return from Help while mutation ran")
|
|
}
|
|
|
|
updated, blocked := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
if blocked != nil || !strings.Contains(model.View(), "Wait for the current mutation to finish") || len(module.intents) != 1 {
|
|
t.Fatalf("conflicting mutation was not explained: intents=%#v\n%s", module.intents, model.View())
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelPreservesExpandableCurrentErrorAndFocusesFirstFailedTarget(t *testing.T) {
|
|
failure := make(chan app.OperationUpdate, 1)
|
|
refresh := make(chan app.OperationUpdate, 1)
|
|
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, refresh}}
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
failedSnapshot := operationSnapshot()
|
|
failedSnapshot.Targets[0].State = target.Enabled
|
|
failedSnapshot.Targets[1].State = target.Drifted
|
|
failure <- app.FailedOperationUpdate(failedSnapshot,
|
|
app.OperationStatus{Summary: "Active Profile changed with one Target failure", TargetID: "apt"},
|
|
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}, {TargetID: "apt", Phase: app.TargetProgressFailed}},
|
|
"apt", errors.New("apt write failed: permission denied"))
|
|
close(failure)
|
|
model, _ = applyOperationCommand(t, model, command)
|
|
if model.selectedTargetID != "apt" {
|
|
t.Fatalf("first failed Target was not selected: %q", model.selectedTargetID)
|
|
}
|
|
view := model.View()
|
|
if !strings.Contains(view, "Current error: Active Profile changed with one Target failure") || strings.Contains(view, "permission denied") {
|
|
t.Fatalf("collapsed current error is wrong:\n%s", view)
|
|
}
|
|
model = updateKey(model, "e")
|
|
if !strings.Contains(model.View(), "apt write failed: permission denied") {
|
|
t.Fatalf("current error did not expand:\n%s", model.View())
|
|
}
|
|
|
|
updated, command = model.Update(keyMessage("r"))
|
|
model = updated.(Model)
|
|
refresh <- app.SucceededOperationUpdate(failedSnapshot,
|
|
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"}, nil)
|
|
close(refresh)
|
|
model, _ = applyOperationCommand(t, model, command)
|
|
if !strings.Contains(model.View(), "Current error: Active Profile changed with one Target failure") {
|
|
t.Fatalf("unrelated success replaced current error:\n%s", model.View())
|
|
}
|
|
model = updateKey(model, "x")
|
|
if strings.Contains(model.View(), "Current error:") || strings.Contains(model.View(), "Active Profile changed with one Target failure") {
|
|
t.Fatalf("acknowledged error remained visible:\n%s", model.View())
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelClearsCurrentErrorForDirectlyRelatedSuccessfulDetection(t *testing.T) {
|
|
failure := make(chan app.OperationUpdate, 1)
|
|
resolution := make(chan app.OperationUpdate, 1)
|
|
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, resolution}}
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
failure <- app.FailedOperationUpdate(module.snapshot,
|
|
app.OperationStatus{Summary: "Shell Target update failed", TargetID: "shell"}, nil, "shell", errors.New("permission denied"))
|
|
close(failure)
|
|
model, _ = applyOperationCommand(t, model, command)
|
|
|
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
resolution <- app.SucceededOperationUpdate(module.snapshot,
|
|
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"},
|
|
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
|
|
close(resolution)
|
|
model, _ = applyOperationCommand(t, model, command)
|
|
if strings.Contains(model.View(), "Current error:") {
|
|
t.Fatalf("explicit related resolution did not clear the current error:\n%s", model.View())
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelReportsOperationStreamClosingWithoutFinalResult(t *testing.T) {
|
|
updates := make(chan app.OperationUpdate)
|
|
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
close(updates)
|
|
model, _ = applyOperationCommand(t, model, command)
|
|
if !strings.Contains(model.View(), "Current error: Operation ended without a final result") || model.operation.active {
|
|
t.Fatalf("unexpected stream closure was not deterministic:\n%s", model.View())
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelUsesStructuredConfirmationAndSafeCancellation(t *testing.T) {
|
|
confirmation := make(chan app.OperationUpdate, 1)
|
|
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{confirmation}}
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
confirmation <- app.ConfirmationOperationUpdate(module.snapshot,
|
|
app.Confirmation{Title: "Confirm Docker restart", Summary: "Running work may be disrupted.", ConfirmLabel: "Restart", CancelLabel: "Cancel", HighImpact: true})
|
|
close(confirmation)
|
|
model, _ = applyOperationCommand(t, model, command)
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 120, 36, "Confirm Docker restart", "Running work may be disrupted", "Enter/n/Esc: Cancel")
|
|
|
|
updated, next := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
model = updated.(Model)
|
|
if next != nil || model.operation.confirmation != nil || len(module.intents) != 1 {
|
|
t.Fatalf("safe-default Enter did not cancel: confirmation=%v intents=%#v", model.operation.confirmation != nil, module.intents)
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelExplainsDisabledTargetMutations(t *testing.T) {
|
|
checking := operationSnapshot()
|
|
checking.ActiveProfile = ""
|
|
module := &interactiveScriptModule{snapshot: checking}
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
model.snapshot.Targets[0].DetectionPhase = app.TargetChecking
|
|
model.snapshot.Targets[0].PrimaryAction.Available = false
|
|
model.snapshot.Targets[0].PrimaryAction.DisabledReason = "Target mutation is disabled while this Target is still being checked"
|
|
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
if command != nil || !strings.Contains(model.View(), "disabled while this Target is still being checked") {
|
|
t.Fatalf("Checking mutation lacked a visible reason:\n%s", model.View())
|
|
}
|
|
|
|
model.snapshot.Targets[0].DetectionPhase = app.TargetReady
|
|
model.snapshot.Targets[0].PrimaryAction.Available = false
|
|
model.snapshot.Targets[0].PrimaryAction.DisabledReason = "Target mutation is disabled because there is no Active Profile; open Profile management first"
|
|
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
if command != nil || !strings.Contains(model.View(), "disabled because there is no Active Profile") {
|
|
t.Fatalf("missing-Profile mutation lacked a visible reason:\n%s", model.View())
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelVisiblyDefersCtrlCFromHelpAndStartsNoNewWork(t *testing.T) {
|
|
updates := make(chan app.OperationUpdate, 1)
|
|
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
model = updateKey(model, "?")
|
|
|
|
updated, quit := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
|
model = updated.(Model)
|
|
if quit != nil || !strings.Contains(model.View(), "Exit requested — waiting for the current mutation to finish safely") {
|
|
t.Fatalf("deferred exit was not visible from Help:\n%s", model.View())
|
|
}
|
|
model = updateKey(model, "q")
|
|
model = updateKey(model, "down")
|
|
if model.selectedTargetID != "apt" {
|
|
t.Fatalf("deferred exit prevented read-only Target browsing: selected=%q", model.selectedTargetID)
|
|
}
|
|
model = updateKey(model, "?")
|
|
if model.page != helpPage {
|
|
t.Fatal("deferred exit prevented opening read-only Help")
|
|
}
|
|
model = updateKey(model, "q")
|
|
for _, key := range []tea.KeyMsg{tea.KeyMsg{Type: tea.KeySpace}, keyMessage("r"), keyMessage("p")} {
|
|
updated, command := model.Update(key)
|
|
model = updated.(Model)
|
|
if command != nil || len(module.intents) != 1 {
|
|
t.Fatalf("new work began after exit request: intents=%#v", module.intents)
|
|
}
|
|
}
|
|
|
|
updates <- app.SucceededOperationUpdate(module.snapshot,
|
|
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
|
|
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
|
|
close(updates)
|
|
model, quit = applyOperationCommand(t, model, operation)
|
|
if quit == nil {
|
|
t.Fatal("safe completion did not queue exit")
|
|
}
|
|
if _, ok := quit().(tea.QuitMsg); !ok {
|
|
t.Fatal("safe completion did not return tea.Quit")
|
|
}
|
|
}
|