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 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}}, }, } } 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" 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 != 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") } }