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", PrimaryAction: app.TargetAction{Kind: app.ApplyProfileTargetAction, Available: true}}, {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", PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}}}}, Notice: "Shell Target is now Enabled"} model := NewModel(module) view := model.View() if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Space: Apply Profile") { 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()) } model, _ = applyOperationCommand(t, model, command) if len(module.intents) != 1 || module.intents[0].Kind != app.ApplyProfileToTarget || !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, PrimaryAction: app.TargetAction{Kind: app.EnableTargetAction, Available: true}}}} 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) model, _ = applyOperationCommand(t, model, command) 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") || strings.Contains(model.View(), "Applying") { t.Fatalf("cancel mutated or left stale progress = intents %#v\n%s", module.intents, model.View()) } updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace}) model = updated.(Model) model, _ = applyOperationCommand(t, model, command) 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, PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}}}}, 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") } 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", PrimaryAction: app.TargetAction{Kind: app.EnableTargetAction, Available: false, DisabledReason: "This action is unavailable because there is no Active Profile; open Profile management first"}}, {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, "Unavailable:") || !strings.Contains(view, "integration installed") { t.Fatalf("main Target panel:\n%s", view) } model = updateKey(model, "?") view = model.View() if !strings.Contains(view, "Contextual Help") || !strings.Contains(view, "Shell") { t.Fatalf("contextual Target help:\n%s", view) } for range 12 { if strings.Contains(view, "Not detected: Debian APT tool apt-config was not detected") && strings.Contains(view, "owns an overlay") { break } model = updateKey(model, "pgdown") view = model.View() } if !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("scrollable Target reference:\n%s", view) } model = updateKey(model, "esc") if !strings.Contains(model.View(), "Profile:") { 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()) } if len(module.intents) != 1 || module.intents[0].Kind != app.RefreshTargets { t.Fatalf("refresh command intents = %#v", module.intents) } model, command = applyOperationCommand(t, model, command) 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", "m", "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", "m", "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) model, _ = applyOperationCommand(t, model, command) 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) model, _ = applyOperationCommand(t, model, command) 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(), "Profile:") { t.Fatalf("help key %q did not return to dashboard", key.String()) } model = updateKey(NewModel(module), "m") updated, command = model.Update(key) if command != nil || !strings.Contains(updated.(Model).View(), "Profile:") { 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") } model, command = applyOperationCommand(t, model, command) if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Profile: new") { t.Fatalf("applying progress:\n%s", model.View()) } model, command = applyOperationCommand(t, model, command) if command == nil || !strings.Contains(model.View(), "Enabled") || !strings.Contains(model.View(), "update completed") { t.Fatalf("Target completion:\n%s", model.View()) } model, _ = applyOperationCommand(t, model, command) 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 TestDriftedTargetWithoutActiveProfileRequiresExplicitResolutionDirection(t *testing.T) { initial := app.Snapshot{Targets: []app.TargetSnapshot{{ ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted, PrimaryAction: app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}, DriftResolutionActions: []app.TargetAction{{Kind: app.ReapplyActiveProfileAction}, {Kind: app.RestoreOriginalAction, Available: true}}, }}} initial.Targets[0].DriftResolutionActions[0].DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile" module := &targetModule{snapshot: initial} model := NewModel(module) updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) model = updated.(Model) if command != nil || len(module.intents) != 0 || !strings.Contains(model.View(), "Drift Resolution") || !strings.Contains(model.View(), "Reapply Active Profile is unavailable") { t.Fatalf("Drifted Target chose a direction implicitly: intents=%#v\n%s", module.intents, model.View()) } } 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, PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}, }}} 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) model, _ = applyOperationCommand(t, model, command) 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) } }