Files
proxy/internal/tui/operation_coordination_test.go
T
2026-07-18 02:56:33 +08:00

448 lines
20 KiB
Go

package tui
import (
"errors"
"strings"
"testing"
"time"
"github.com/charmbracelet/bubbles/spinner"
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 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: m.snapshot, Updates: stream}
}
func operationSnapshot() app.Snapshot {
return 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, PrimaryAction: app.TargetAction{Kind: app.EnableTargetAction, Available: true}},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT details"}, Available: true, State: target.Enabled, PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}},
},
}
}
// applyOperationCommand drives the same command shape Bubble Tea drives in
// production. An operation starts the stream wait and spinner tick as an
// independent Batch; tests select the stream result without coupling its
// completion to animation timing.
func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) {
t.Helper()
if command == nil {
t.Fatal("operation command is nil")
}
message, ok := operationStreamMessage(command())
if !ok {
t.Fatal("operation command produced no operation-stream message")
}
updated, next := model.Update(message)
return updated.(Model), next
}
func operationStreamMessage(message tea.Msg) (tea.Msg, bool) {
switch typed := message.(type) {
case operationProgress:
return typed, true
case tea.BatchMsg:
for _, command := range typed {
if command == nil {
continue
}
if message, ok := operationStreamMessage(command()); ok {
return message, true
}
}
case spinner.TickMsg:
// Spinner ticks have their own production command chain. Applying one in
// this helper would schedule animation work unrelated to stream progress.
return nil, false
}
return nil, false
}
func TestCompleteModelSpinnerAnimationHasDeterministicCadence(t *testing.T) {
if got, want := applyingSpinner.FPS, 90*time.Millisecond; got != want {
t.Fatalf("spinner cadence = %s, want %s", got, want)
}
if got, want := strings.Join(applyingSpinner.Frames, ""), "|/-\\"; got != want {
t.Fatalf("spinner frames = %q, want %q", got, want)
}
}
func TestCompleteModelUsesAnimatedBubblesSpinnerOnlyWhileOperationRuns(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)
if command == nil || !strings.Contains(model.View(), "| Applying") {
t.Fatalf("operation did not start the Bubbles spinner:\n%s", model.View())
}
message := command()
batch, ok := message.(tea.BatchMsg)
if !ok || len(batch) != 2 {
t.Fatalf("operation command = %T with %d children, want independent stream and spinner Batch", message, len(batch))
}
updated, next := model.Update(spinner.TickMsg{ID: model.operation.spinner.ID()})
model = updated.(Model)
if next == nil || !strings.Contains(model.View(), "/ Applying") {
t.Fatalf("spinner tick did not animate the in-row Applying marker:\n%s", model.View())
}
terminal := app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
updated, _ = model.Update(operationProgress{update: terminal, updates: updates})
model = updated.(Model)
terminalView := model.View()
updated, stale := model.Update(spinner.TickMsg{ID: model.operation.spinner.ID()})
model = updated.(Model)
if stale != nil || model.View() != terminalView || strings.Contains(model.View(), "Applying") {
t.Fatalf("spinner tick changed terminal operation state:\n%s", model.View())
}
}
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 != mainPage || !model.help.open || !strings.Contains(model.View(), "Contextual Help") {
t.Fatalf("Help was not reachable as a dashboard dialog 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 TestCompleteModelReadOnlyRefreshDoesNotClearTargetMutationErrorForSameTarget(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)
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(keyMessage("r"))
model = updated.(Model)
refresh <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(refresh)
model, _ = applyOperationCommand(t, model, command)
if !strings.Contains(model.View(), "Current error: Shell Target update failed") {
t.Fatalf("read-only Refresh Targets cleared an unresolved mutation error:\n%s", model.View())
}
}
func TestCompleteModelFailedRefreshDoesNotReplaceTargetMutationError(t *testing.T) {
mutationFailure := make(chan app.OperationUpdate, 1)
refreshFailure := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{mutationFailure, refreshFailure}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
mutationFailure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target update failed", TargetID: "shell"}, nil, "shell", errors.New("mutation permission denied"))
close(mutationFailure)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
refreshFailure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Some Target checks failed", TargetID: "apt"}, nil, "apt", errors.New("refresh inspection failed"))
close(refreshFailure)
model, _ = applyOperationCommand(t, model, command)
view := model.View()
if !strings.Contains(view, "Current error: Shell Target update failed") {
t.Fatalf("failed Refresh Targets replaced the current mutation error:\n%s", view)
}
if !strings.Contains(view, "Status: Some Target checks failed") {
t.Fatalf("failed Refresh Targets status was not shown separately:\n%s", view)
}
model = updateKey(model, "e")
view = model.View()
if !strings.Contains(view, "mutation permission denied") || strings.Contains(view, "refresh inspection failed") {
t.Fatalf("expanded current error did not preserve the mutation detail:\n%s", view)
}
}
func TestCompleteModelRelatedTargetMutationFailureReplacesErrorForSameTarget(t *testing.T) {
firstFailure := make(chan app.OperationUpdate, 1)
relatedFailure := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{firstFailure, relatedFailure}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
firstFailure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Initial Shell Target failure", TargetID: "shell"}, nil, "shell", errors.New("initial detail"))
close(firstFailure)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
relatedFailure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Latest Shell Target failure", TargetID: "shell"}, nil, "shell", errors.New("latest detail"))
close(relatedFailure)
model, _ = applyOperationCommand(t, model, command)
view := model.View()
if !strings.Contains(view, "Current error: Latest Shell Target failure") || strings.Contains(view, "Initial Shell Target failure") {
t.Fatalf("related Target mutation failure did not replace the prior error:\n%s", view)
}
model = updateKey(model, "e")
if view := model.View(); !strings.Contains(view, "latest detail") || strings.Contains(view, "initial detail") {
t.Fatalf("related Target mutation failure did not replace the prior detail:\n%s", view)
}
}
func TestCompleteModelRelatedTargetMutationClearsErrorForSameTarget(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 is now Enabled", 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 Target resolution did not clear the current error:\n%s", model.View())
}
}
func TestCompleteModelSuccessfulRefreshClearsOnlyRefreshError(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(keyMessage("r"))
model = updated.(Model)
failure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Some Target checks failed", TargetID: "shell"}, nil, "shell", errors.New("inspection failed"))
close(failure)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
resolution <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Target availability and state checks completed"}, nil)
close(resolution)
model, _ = applyOperationCommand(t, model, command)
if strings.Contains(model.View(), "Current error:") {
t.Fatalf("successful Refresh Targets did not clear its own 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", "n/q/Esc: cancel", "> 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"
model.operation.active = true
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command != nil || model.operation.status.Summary != "Target mutation is disabled while this Target is still being checked" {
t.Fatalf("Checking reason was masked by operation serialization: %q\n%s", model.operation.status.Summary, model.View())
}
model.operation.active = false
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 != mainPage || !model.help.open {
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")
}
}