Files
kportal/internal/ui/handlers_test.go
T
lukaszraczylo bfe541565b feat(ui): toggle httpLog per-forward in add/edit wizard
Adds an HTTP-log enable toggle to the wizard's confirmation step so
users can flip httpLog on a forward without editing YAML by hand.

Behaviour:
- 'h' on the confirmation step toggles HTTPLog when not focused on
  the alias text input. When focus is on alias, 'h' is treated as
  text so users can still type aliases like 'host' or 'http-proxy'.
- The confirmation summary shows '[x] enabled' or '[ ] disabled'.
- New forwards: toggle on -> &HTTPLogSpec{Enabled: true}; off -> nil.
- Edit mode: pre-populates the toggle from the existing forward and
  preserves any advanced HTTPLog fields the user had configured in
  YAML (logFile, includeHeaders, maxBodySize, filterPath) by copying
  the original spec on save. Toggling off discards the advanced
  fields (consistent with 'absent in YAML = disabled').

State changes:
- ForwardStatus gains *config.HTTPLogSpec so the wizard can see the
  full original spec on edit.
- AddWizardState gains httpLog bool + httpLogOriginal *HTTPLogSpec.

Three new tests:
- TestHandleAddWizardKeys_HToggleHTTPLog
- TestHandleAddWizardKeys_HOnAliasFocusIsTextInput
- TestEditPrefill_PreservesHTTPLog
2026-05-06 12:41:36 +01:00

1080 lines
31 KiB
Go

package ui
import (
"errors"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper to create a model for testing
func newTestModel() model {
ui := NewBubbleTeaUI(nil, "1.0.0")
return model{ui: ui, termWidth: 120, termHeight: 40}
}
// Helper to create a model with a forward
func newTestModelWithForward() model {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
Alias: "my-app",
}
ui.AddForward("test-id", fwd)
return model{ui: ui, termWidth: 120, termHeight: 40}
}
// TestHandleMainViewKeys_Quit tests quit key handling
func TestHandleMainViewKeys_Quit(t *testing.T) {
tests := []struct {
key string
expected bool
}{
{"q", true},
{"ctrl+c", true},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
m := newTestModel()
_, cmd := m.handleMainViewKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)})
if tt.key == "ctrl+c" {
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
_, cmd = m.handleMainViewKeys(keyMsg)
}
// tea.Quit returns a special command
if tt.expected {
assert.NotNil(t, cmd)
}
})
}
}
// TestHandleMainViewKeys_Navigation tests cursor navigation
func TestHandleMainViewKeys_Navigation(t *testing.T) {
m := newTestModelWithForward()
// Add more forwards for navigation testing
for i := 0; i < 5; i++ {
fwd := &config.Forward{
Resource: "pod/app",
Port: 8080 + i,
LocalPort: 8080 + i,
}
m.ui.AddForward(string(rune('a'+i)), fwd)
}
tests := []struct {
name string
key string
keyType tea.KeyType
initialIndex int
expectedIndex int
}{
{"down arrow", "down", tea.KeyDown, 0, 1},
{"j key", "j", tea.KeyRunes, 0, 1},
{"up arrow", "up", tea.KeyUp, 2, 1},
{"k key", "k", tea.KeyRunes, 2, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m.ui.mu.Lock()
m.ui.selectedIndex = tt.initialIndex
m.ui.mu.Unlock()
var keyMsg tea.KeyMsg
if tt.keyType == tea.KeyRunes {
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)}
} else {
keyMsg = tea.KeyMsg{Type: tt.keyType}
}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, tt.expectedIndex, m.ui.selectedIndex)
m.ui.mu.RUnlock()
})
}
}
// TestHandleMainViewKeys_Toggle tests space/enter toggle
func TestHandleMainViewKeys_Toggle(t *testing.T) {
toggleCallback := NewMockToggleCallback()
ui := NewBubbleTeaUI(toggleCallback.GetFunc(), "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Toggle with space
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
m.handleMainViewKeys(keyMsg)
// Check disabled state changed
m.ui.mu.RLock()
isDisabled := m.ui.disabledMap["test-id"]
m.ui.mu.RUnlock()
assert.True(t, isDisabled)
// Give callback goroutine time to execute
time.Sleep(10 * time.Millisecond)
// Verify callback was called
assert.GreaterOrEqual(t, toggleCallback.CallCount(), 1)
}
// TestHandleMainViewKeys_NewWizard tests 'n' key with dependencies
func TestHandleMainViewKeys_NewWizard(t *testing.T) {
mockDiscovery := NewMockDiscovery()
mockMutator := NewMockMutator()
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, nil, "/path/to/config") // Real Discovery/Mutator needed
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Without dependencies, 'n' should do nothing
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
assert.Nil(t, m.ui.addWizard, "Wizard should not be created without dependencies")
m.ui.mu.RUnlock()
// With mock (but we can't inject easily due to concrete types)
// This test documents the expected behavior
_ = mockDiscovery
_ = mockMutator
}
// TestHandleMainViewKeys_DeleteConfirmation tests 'd' key
func TestHandleMainViewKeys_DeleteConfirmation(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
Alias: "my-app",
}
ui.AddForward("test-id", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' to show delete confirmation
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
assert.True(t, m.ui.deleteConfirming)
assert.Equal(t, "test-id", m.ui.deleteConfirmID)
assert.Equal(t, "my-app", m.ui.deleteConfirmAlias)
assert.Equal(t, 1, m.ui.deleteConfirmCursor) // Default to "No"
m.ui.mu.RUnlock()
}
// TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate tests that 'd' doesn't overwrite
func TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
fwd1 := &config.Forward{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "app1"}
fwd2 := &config.Forward{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "app2"}
ui.AddForward("id-1", fwd1)
ui.AddForward("id-2", fwd2)
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' for first forward
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
m.handleMainViewKeys(keyMsg)
// Change selection
m.ui.mu.Lock()
m.ui.selectedIndex = 1
m.ui.mu.Unlock()
// Press 'd' again - should not change confirmation
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, "id-1", m.ui.deleteConfirmID, "Delete confirmation should not be overwritten")
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_Cancel tests Esc cancels delete
func TestHandleDeleteConfirmation_Cancel(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Set up delete confirmation
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmAlias = "test-alias"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
assert.False(t, m.ui.deleteConfirming)
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_NavigateAndConfirm tests cursor navigation in delete dialog
func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Note: We use SetWizardDependencies with a real (nil) mutator since
// the navigation test doesn't actually call mutator methods
ui.SetWizardDependencies(nil, nil, "/path/to/config")
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmCursor = 1 // Start on "No"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Navigate left to "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 0, m.ui.deleteConfirmCursor)
m.ui.mu.RUnlock()
// Navigate right back to "No"
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 1, m.ui.deleteConfirmCursor)
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_ConfirmYes tests confirming deletion
func TestHandleDeleteConfirmation_ConfirmYes(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Note: The mutator needs to be set for the command to be generated,
// but we don't call the actual mutator method in this test (just generate the cmd)
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmCursor = 0 // On "Yes"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Enter on "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleDeleteConfirmation(keyMsg)
// Should return a command to remove the forward
assert.NotNil(t, cmd)
// Dialog should be closed
m.ui.mu.RLock()
assert.False(t, m.ui.deleteConfirming)
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_QuickYKey tests 'y' key for quick confirm
func TestHandleDeleteConfirmation_QuickYKey(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Set up with a real mutator (empty but valid) since we're testing command generation
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmCursor = 1 // On "No"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'y' - should confirm regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}
_, cmd := m.handleDeleteConfirmation(keyMsg)
assert.NotNil(t, cmd)
m.ui.mu.RLock()
assert.False(t, m.ui.deleteConfirming)
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_QuickNKey tests 'n' key for quick cancel
func TestHandleDeleteConfirmation_QuickNKey(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmCursor = 0 // On "Yes"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'n' - should cancel regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
assert.False(t, m.ui.deleteConfirming)
m.ui.mu.RUnlock()
}
// TestHandleBenchmarkKeys_Cancel tests benchmark cancellation
func TestHandleBenchmarkKeys_Cancel(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
cancelled := false
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.benchmarkState.cancelFunc = func() { cancelled = true }
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleBenchmarkKeys(keyMsg)
assert.True(t, cancelled, "Cancel function should be called")
m.ui.mu.RLock()
assert.Nil(t, m.ui.benchmarkState)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
// TestHandleBenchmarkKeys_Navigation tests benchmark config navigation
func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Initial cursor is 0
m.ui.mu.RLock()
assert.Equal(t, 0, m.ui.benchmarkState.cursor)
m.ui.mu.RUnlock()
// Move down
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
m.ui.mu.RUnlock()
// Move down again
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 2, m.ui.benchmarkState.cursor)
m.ui.mu.RUnlock()
// Move up
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
m.ui.mu.RUnlock()
}
// TestHandleHTTPLogKeys_Close tests HTTP log view closing
func TestHandleHTTPLogKeys_Close(t *testing.T) {
mockSubscriber := NewMockHTTPLogSubscriber()
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.httpLogCleanup = mockSubscriber.Subscribe("fwd-id", func(entry HTTPLogEntry) {})
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Nil(t, m.ui.httpLogState)
assert.Nil(t, m.ui.httpLogCleanup)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
// Verify cleanup was called
assert.Equal(t, 1, mockSubscriber.CleanupCalls)
}
// TestHandleHTTPLogKeys_FilterCycle tests filter mode cycling
func TestHandleHTTPLogKeys_FilterCycle(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Initial mode is None
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
m.ui.mu.RUnlock()
// Press 'f' to cycle - should skip Text mode and go to Non200
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterNon200, m.ui.httpLogState.filterMode)
m.ui.mu.RUnlock()
// Press 'f' again - should go to Errors
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterErrors, m.ui.httpLogState.filterMode)
m.ui.mu.RUnlock()
// Press 'f' again - should go back to None
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
m.ui.mu.RUnlock()
}
// TestHandleHTTPLogKeys_TextFilter tests '/' for text filter
func TestHandleHTTPLogKeys_TextFilter(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press '/'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.True(t, m.ui.httpLogState.filterActive)
m.ui.mu.RUnlock()
}
// TestHandleHTTPLogKeys_ClearFilters tests 'c' to clear filters
func TestHandleHTTPLogKeys_ClearFilters(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.httpLogState.filterMode = HTTPLogFilterErrors
ui.httpLogState.filterText = "api"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
assert.Empty(t, m.ui.httpLogState.filterText)
m.ui.mu.RUnlock()
}
// TestHandleHTTPLogEntry tests HTTP log entry handling
func TestHandleHTTPLogEntry(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.httpLogState.autoScroll = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Send an entry
msg := HTTPLogEntryMsg{
Entry: HTTPLogEntry{
Method: "GET",
Path: "/api/test",
StatusCode: 200,
},
}
m.handleHTTPLogEntry(msg)
m.ui.mu.RLock()
assert.Len(t, m.ui.httpLogState.entries, 1)
assert.Equal(t, "/api/test", m.ui.httpLogState.entries[0].Path)
m.ui.mu.RUnlock()
}
// TestHandleContextsLoaded tests context loading handler
func TestHandleContextsLoaded(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.loading = true
// Note: discovery is nil but the handler doesn't use it directly,
// it uses the message data instead. The current context reordering
// uses GetCurrentContext() which would fail with nil discovery,
// but we test the basic loading behavior here.
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Simulate contexts loaded
msg := ContextsLoadedMsg{
contexts: []string{"default", "production", "staging"},
}
m.handleContextsLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
// Contexts should be loaded (order depends on GetCurrentContext which may fail with nil discovery)
assert.Contains(t, m.ui.addWizard.contexts, "default")
assert.Contains(t, m.ui.addWizard.contexts, "production")
assert.Contains(t, m.ui.addWizard.contexts, "staging")
m.ui.mu.RUnlock()
}
// TestHandleContextsLoaded_Error tests error handling
func TestHandleContextsLoaded_Error(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.loading = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Simulate error
expectedErr := errors.New("failed to list contexts")
msg := ContextsLoadedMsg{
err: expectedErr,
}
m.handleContextsLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Equal(t, expectedErr, m.ui.addWizard.error)
m.ui.mu.RUnlock()
}
// TestHandleNamespacesLoaded tests namespace loading handler
func TestHandleNamespacesLoaded(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.loading = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := NamespacesLoadedMsg{
namespaces: []string{"default", "kube-system", "production"},
}
m.handleNamespacesLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Equal(t, []string{"default", "kube-system", "production"}, m.ui.addWizard.namespaces)
m.ui.mu.RUnlock()
}
// TestHandlePodsLoaded tests pod loading handler
func TestHandlePodsLoaded(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.loading = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
pods := []k8s.PodInfo{
{Name: "app-1", Namespace: "default"},
{Name: "app-2", Namespace: "default"},
}
msg := PodsLoadedMsg{pods: pods}
m.handlePodsLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Len(t, m.ui.addWizard.pods, 2)
m.ui.mu.RUnlock()
}
// TestHandleServicesLoaded tests service loading handler
func TestHandleServicesLoaded(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.loading = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
services := []k8s.ServiceInfo{
{Name: "api", Namespace: "default", Ports: []k8s.PortInfo{{Port: 80}}},
{Name: "db", Namespace: "default", Ports: []k8s.PortInfo{{Port: 5432}}},
}
msg := ServicesLoadedMsg{services: services}
m.handleServicesLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Len(t, m.ui.addWizard.services, 2)
m.ui.mu.RUnlock()
}
// TestHandleSelectorValidated tests selector validation handler
func TestHandleSelectorValidated(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.loading = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
pods := []k8s.PodInfo{
{Name: "app-1", Namespace: "default"},
}
msg := SelectorValidatedMsg{
valid: true,
pods: pods,
}
m.handleSelectorValidated(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Len(t, m.ui.addWizard.matchingPods, 1)
m.ui.mu.RUnlock()
}
// TestHandlePortChecked tests port availability check handler
func TestHandlePortChecked(t *testing.T) {
tests := []struct {
name string
expectStep AddWizardStep
available bool
expectError bool
}{
{name: "port available", available: true, expectStep: StepConfirmation, expectError: false},
{name: "port in use", available: false, expectStep: StepEnterLocalPort, expectError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.step = StepEnterLocalPort
ui.addWizard.loading = true
ui.addWizard.localPort = 8080
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := PortCheckedMsg{
port: 8080,
available: tt.available,
message: "test message",
}
m.handlePortChecked(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Equal(t, tt.available, m.ui.addWizard.portAvailable)
if tt.expectError {
assert.NotNil(t, m.ui.addWizard.error)
} else {
assert.Equal(t, tt.expectStep, m.ui.addWizard.step)
}
m.ui.mu.RUnlock()
})
}
}
// TestHandleForwardSaved tests forward save handler
func TestHandleForwardSaved(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.step = StepConfirmation
ui.addWizard.loading = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := ForwardSavedMsg{success: true}
m.handleForwardSaved(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Equal(t, StepSuccess, m.ui.addWizard.step)
m.ui.mu.RUnlock()
}
// TestHandleForwardsRemoved tests forward removal handler
func TestHandleForwardsRemoved(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := ForwardsRemovedMsg{success: true, count: 2}
m.handleForwardsRemoved(msg)
m.ui.mu.RLock()
assert.Nil(t, m.ui.removeWizard)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
// TestHandleBenchmarkProgress tests benchmark progress handler
func TestHandleBenchmarkProgress(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.benchmarkState.running = true
ui.benchmarkState.progressCh = make(chan BenchmarkProgressMsg, 1)
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := BenchmarkProgressMsg{
ForwardID: "fwd-id",
Completed: 50,
Total: 100,
}
m.handleBenchmarkProgress(msg)
m.ui.mu.RLock()
assert.Equal(t, 50, m.ui.benchmarkState.progress)
assert.Equal(t, 100, m.ui.benchmarkState.total)
m.ui.mu.RUnlock()
}
// TestHandleBenchmarkComplete tests benchmark completion handler
func TestHandleBenchmarkComplete(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.benchmarkState.running = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Note: This test documents expected behavior
// The actual BenchmarkCompleteMsg requires benchmark.Results which has CalculateStats
msg := BenchmarkCompleteMsg{
ForwardID: "fwd-id",
Error: errors.New("test error"),
}
m.handleBenchmarkComplete(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.benchmarkState.running)
assert.Equal(t, BenchmarkStepResults, m.ui.benchmarkState.step)
assert.NotNil(t, m.ui.benchmarkState.error)
m.ui.mu.RUnlock()
}
// TestModel_Update_MessageRouting tests message routing in Update
func TestModel_Update_MessageRouting(t *testing.T) {
m := newTestModelWithForward()
// Test window size message
sizeMsg := tea.WindowSizeMsg{Width: 100, Height: 50}
newModel, _ := m.Update(sizeMsg)
updatedModel := newModel.(model)
assert.Equal(t, 100, updatedModel.termWidth)
assert.Equal(t, 50, updatedModel.termHeight)
}
// TestModel_Update_ViewModeRouting tests that key messages are routed based on view mode
func TestModel_Update_ViewModeRouting(t *testing.T) {
tests := []struct {
name string
viewMode ViewMode
}{
{"main view", ViewModeMain},
{"add wizard", ViewModeAddWizard},
{"benchmark", ViewModeBenchmark},
{"http log", ViewModeHTTPLog},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = tt.viewMode
switch tt.viewMode {
case ViewModeAddWizard:
ui.addWizard = newAddWizardState()
case ViewModeBenchmark:
ui.benchmarkState = newBenchmarkState("id", "alias", 8080)
case ViewModeHTTPLog:
ui.httpLogState = newHTTPLogState("id", "alias")
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Send a key message - should not panic
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
_, _ = m.Update(keyMsg)
})
}
}
// TestWizardCompleteMsg tests wizard completion message handling
func TestWizardCompleteMsg(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := WizardCompleteMsg{}
newModel, _ := m.Update(msg)
updatedModel := newModel.(model)
updatedModel.ui.mu.RLock()
assert.Equal(t, ViewModeMain, updatedModel.ui.viewMode)
assert.Nil(t, updatedModel.ui.addWizard)
updatedModel.ui.mu.RUnlock()
}
// Helper to check that model implements tea.Model
func TestModel_ImplementsTeaModel(t *testing.T) {
m := newTestModel()
var _ tea.Model = m
require.NotNil(t, m)
}
// TestHandleRemoveWizardKeys_EscInConfirmingCancels verifies that pressing Esc
// while the remove wizard is in confirming state CANCELS the confirmation
// instead of dispatching the deletion command. The help text on the
// confirmation screen advertises "Esc: Cancel" — destructive Esc was a P0 UX bug.
func TestHandleRemoveWizardKeys_EscInConfirmingCancels(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{
{ID: "fwd-1", Alias: "alpha"},
{ID: "fwd-2", Alias: "beta"},
},
selected: map[int]bool{0: true, 1: true},
confirming: true,
confirmCursor: 0, // cursor on "Yes" — worst case: reflexive Esc would have triggered Yes
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// No removal command must be dispatched.
assert.Nil(t, cmd, "Esc in confirming state must NOT dispatch removeForwardsCmd")
m.ui.mu.RLock()
defer m.ui.mu.RUnlock()
// Wizard must remain alive (we returned to selection, not aborted entirely).
require.NotNil(t, m.ui.removeWizard, "wizard should still exist after cancelling confirmation")
// Confirming flag must be cleared.
assert.False(t, m.ui.removeWizard.confirming, "wizard.confirming must be false after Esc cancels")
// View mode unchanged.
assert.Equal(t, ViewModeRemoveWizard, m.ui.viewMode, "view mode should remain in remove wizard")
// Selections preserved so user can re-confirm or adjust.
assert.True(t, m.ui.removeWizard.selected[0])
assert.True(t, m.ui.removeWizard.selected[1])
}
// TestHandleRemoveWizardKeys_EscNotConfirmingExits verifies that Esc still
// exits the wizard entirely when not in confirming state.
func TestHandleRemoveWizardKeys_EscNotConfirmingExits(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "fwd-1", Alias: "alpha"}},
selected: map[int]bool{},
confirming: false,
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// Should return tea.ClearScreen command on full exit.
assert.NotNil(t, cmd, "Esc outside confirmation should return ClearScreen cmd")
m.ui.mu.RLock()
defer m.ui.mu.RUnlock()
assert.Nil(t, m.ui.removeWizard, "wizard should be nil after exit")
assert.Equal(t, ViewModeMain, m.ui.viewMode)
}
// TestHandleRemoveWizardKeys_EnterOnYesStillConfirms verifies that the Enter-on-Yes
// path still produces a removal command (regression guard around the Esc fix).
func TestHandleRemoveWizardKeys_EnterOnYesStillConfirms(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "fwd-1", Alias: "alpha"}},
selected: map[int]bool{0: true},
confirming: true,
confirmCursor: 0, // Yes
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.NotNil(t, cmd, "Enter on Yes must still dispatch removeForwardsCmd")
}
// TestHandleAddWizardKeys_HToggleHTTPLog verifies that pressing 'h' on the
// confirmation step (when not focused on the alias text input) flips the
// httpLog flag on the wizard state.
func TestHandleAddWizardKeys_HToggleHTTPLog(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.step = StepConfirmation
ui.addWizard.confirmationFocus = FocusButtons
ui.addWizard.inputMode = InputModeList
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
require.False(t, m.ui.addWizard.httpLog, "httpLog should default to false")
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
m.handleAddWizardKeys(keyMsg)
assert.True(t, m.ui.addWizard.httpLog, "first 'h' should enable httpLog")
m.handleAddWizardKeys(keyMsg)
assert.False(t, m.ui.addWizard.httpLog, "second 'h' should disable httpLog")
}
// TestHandleAddWizardKeys_HOnAliasFocusIsTextInput verifies that 'h' is
// treated as a regular character when the alias text input has focus, so the
// user can still type aliases like "host" or "http-proxy".
func TestHandleAddWizardKeys_HOnAliasFocusIsTextInput(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.step = StepConfirmation
ui.addWizard.confirmationFocus = FocusAlias
ui.addWizard.inputMode = InputModeList
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
m.handleAddWizardKeys(keyMsg)
assert.False(t, m.ui.addWizard.httpLog, "httpLog must NOT toggle when alias has focus")
assert.Contains(t, m.ui.addWizard.textInput, "h", "'h' should land in alias text input")
}
// TestEditPrefill_PreservesHTTPLog verifies that opening the wizard in edit
// mode for a forward whose ForwardStatus has HTTPLog set initialises the
// wizard's httpLog flag and httpLogOriginal pointer correctly.
func TestEditPrefill_PreservesHTTPLog(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
disco := &k8s.Discovery{}
ui.SetWizardDependencies(disco, &config.Mutator{}, "/path/to/config")
fwd := &config.Forward{
Resource: "pod/api",
Port: 8080,
LocalPort: 8080,
HTTPLog: &config.HTTPLogSpec{Enabled: true, IncludeHeaders: true, MaxBodySize: 4096},
}
ui.AddForward("api", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}
m.handleMainViewKeys(keyMsg)
require.NotNil(t, m.ui.addWizard, "wizard should be active after 'e'")
assert.True(t, m.ui.addWizard.isEditing)
assert.True(t, m.ui.addWizard.httpLog, "httpLog flag should reflect existing forward")
require.NotNil(t, m.ui.addWizard.httpLogOriginal, "original spec should be retained for advanced fields")
assert.True(t, m.ui.addWizard.httpLogOriginal.IncludeHeaders)
assert.Equal(t, 4096, m.ui.addWizard.httpLogOriginal.MaxBodySize)
}