Files
kportal/internal/ui/wizard_handlers_extended_test.go
T
lukaszraczylo 90ddca6709 test: cover ui handlers/views/commands/table
internal/ui: 27.9% -> 79.8%. Adds three test files (~3300 lines):
  - wizard_handlers_extended_test.go: keyboard branches not previously
    covered (ctrl+c, pgup/pgdn, search filter typing, port-checked
    failure path, alias text edit, focus toggling, error states),
    plus removeForwardsCmd / removeForwardByIDCmd against real Mutator
    + tempdir config (dedup + at-least-one-namespace validation).
  - wizard_views_test.go: every renderXxx path with a populated
    AddWizardState — context/namespace selectors, resource selection,
    port entry, http log list/detail, success, scroll indicators.
  - table_test.go: AddForward / UpdateStatus / SetError / Clear /
    FilterStrings flows.

Test-only fixups noted by agent:
  - getFilteredEntries silently skips StatusCode==0 entries (only
    completed responses are visible). Tests now seed StatusCode=200.
  - handleForwardSaved leaves step=StepConfirmation on save error
    rather than advancing to StepSuccess; assertion corrected.
  - removeForwardsCmd needs >=2 forwards in a context for dedup tests
    so removing one leaves the context non-empty (validator rejects
    empty contexts).

go test -race -count=1 ./... clean across all 14 packages.
2026-05-06 14:08:58 +01:00

1694 lines
50 KiB
Go

package ui
import (
"os"
"testing"
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"
)
// ---- helpers -------------------------------------------------------------
func newModelWithBenchmark() model {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.mu.Unlock()
return model{ui: ui, termWidth: 120, termHeight: 40}
}
func newModelWithHTTPLog() model {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.mu.Unlock()
return model{ui: ui, termWidth: 120, termHeight: 40}
}
// ---- handleMainViewKeys: pgup / pgdown -----------------------------------
func TestHandleMainViewKeys_PageUpDown(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
for i := 0; i < 15; i++ {
fwd := &config.Forward{Resource: "pod/app", Port: int(8080 + i), LocalPort: int(8080 + i)}
ui.AddForward(string(rune('a'+i)), fwd)
}
ui.mu.Lock()
ui.selectedIndex = 10
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, ui.selectedIndex)
ui.mu.RUnlock()
// Reset and test pgdown.
ui.mu.Lock()
ui.selectedIndex = 0
ui.mu.Unlock()
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 10, ui.selectedIndex)
ui.mu.RUnlock()
}
// ---- handleMainViewKeys: 'n' with real discovery (kubeconfig optional) --
// TestHandleMainViewKeys_NewWizard_WithDiscovery checks that the 'n' key activates
// the add wizard when a discovery + mutator are set. The actual loadContextsCmd
// is NOT invoked (we only verify the wizard is opened and a cmd is returned).
func TestHandleMainViewKeys_NewWizard_WithDiscovery(t *testing.T) {
realDiscovery := &k8s.Discovery{}
realMutator := &config.Mutator{}
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.discovery = realDiscovery
ui.mutator = realMutator
ui.configPath = "/tmp/test.yaml"
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
_, cmd := m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, ViewModeAddWizard, ui.viewMode)
require.NotNil(t, ui.addWizard)
ui.mu.RUnlock()
// The wizard was opened and a cmd was returned (loadContextsCmd).
assert.NotNil(t, cmd)
}
// ---- handleMainViewKeys: 'n' blocks when wizard already active ----------
func TestHandleMainViewKeys_NewWizard_AlreadyActive(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.discovery = &k8s.Discovery{}
ui.mutator = &config.Mutator{}
ui.mu.Lock()
ui.addWizard = newAddWizardState() // wizard already active
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
m.handleMainViewKeys(keyMsg)
// Should still be the same wizard (not replaced).
ui.mu.RLock()
assert.Equal(t, StepSelectContext, ui.addWizard.step)
ui.mu.RUnlock()
}
// ---- handleMainViewKeys: 'l' HTTP log subscription ----------------------
func TestHandleMainViewKeys_HttpLog_NoSubscriber(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"}
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, ViewModeHTTPLog, ui.viewMode)
require.NotNil(t, ui.httpLogState)
ui.mu.RUnlock()
}
func TestHandleMainViewKeys_HttpLog_WithSubscriber(t *testing.T) {
mockSub := NewMockHTTPLogSubscriber()
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetHTTPLogSubscriber(mockSub.GetSubscriberFunc())
fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"}
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}
m.handleMainViewKeys(keyMsg)
// Subscription should be established.
mockSub.mu.Lock()
_, subscribed := mockSub.Subscriptions["id-1"]
mockSub.mu.Unlock()
assert.True(t, subscribed)
}
// ---- handleMainViewKeys: 'b' benchmark ----------------------------------
func TestHandleMainViewKeys_Benchmark(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"}
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, ViewModeBenchmark, ui.viewMode)
require.NotNil(t, ui.benchmarkState)
ui.mu.RUnlock()
}
func TestHandleMainViewKeys_Benchmark_BlocksWhenActive(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"}
ui.AddForward("id-1", fwd)
ui.mu.Lock()
ui.benchmarkState = newBenchmarkState("id-1", "app", 8080)
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
prevState := ui.benchmarkState
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}
m.handleMainViewKeys(keyMsg)
// Should not have replaced the benchmark state.
ui.mu.RLock()
assert.Equal(t, prevState, ui.benchmarkState)
ui.mu.RUnlock()
}
// ---- handleAddWizardKeys: ctrl+c / esc ----------------------------------
func TestHandleAddWizardKeys_CtrlC(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
m.ui.mu.RLock()
assert.Nil(t, m.ui.addWizard)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
func TestHandleAddWizardKeys_Esc_FirstStep_Cancels(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
assert.Nil(t, m.ui.addWizard)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
func TestHandleAddWizardKeys_Esc_MiddleStep_GoesBack(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, StepSelectContext, m.ui.addWizard.step)
m.ui.mu.RUnlock()
}
func TestHandleAddWizardKeys_Esc_ClearsSearchFilter_InsteadOfBack(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = "my"
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
// Should not go back, should just clear filter.
assert.Equal(t, StepSelectContext, m.ui.addWizard.step)
assert.Empty(t, m.ui.addWizard.searchFilter)
m.ui.mu.RUnlock()
}
func TestHandleAddWizardKeys_Esc_EditMode_AlwaysCancels(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.isEditing = true
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
assert.Nil(t, m.ui.addWizard)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
// ---- handleAddWizardKeys: navigation ------------------------------------
func TestHandleAddWizardKeys_Navigation(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.contexts = []string{"ctx-a", "ctx-b", "ctx-c"}
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 1, m.ui.addWizard.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 0, m.ui.addWizard.cursor)
}
func TestHandleAddWizardKeys_PageNavigation(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
contexts := make([]string, 30)
for i := range contexts {
contexts[i] = "ctx"
}
m.ui.addWizard.contexts = contexts
m.ui.addWizard.cursor = 15
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 5, m.ui.addWizard.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 15, m.ui.addWizard.cursor)
}
// ---- handleAddWizardKeys: confirmation step navigation ------------------
func TestHandleAddWizardKeys_ConfirmationStep_UpFocusAlias(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusButtons
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusAlias, m.ui.addWizard.confirmationFocus)
}
func TestHandleAddWizardKeys_ConfirmationStep_DownFocusButtons(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
}
func TestHandleAddWizardKeys_Tab_TogglesFocus(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusAlias, m.ui.addWizard.confirmationFocus)
}
// ---- handleAddWizardKeys: backspace ------------------------------------
func TestHandleAddWizardKeys_Backspace_TextMode(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "808"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "80", m.ui.addWizard.textInput)
}
func TestHandleAddWizardKeys_Backspace_SearchFilter(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = "my"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "m", m.ui.addWizard.searchFilter)
}
func TestHandleAddWizardKeys_Backspace_EmptyFilter_NoOp(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = ""
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
// Must not panic.
m.handleAddWizardKeys(keyMsg)
}
// ---- handleAddWizardKeys: text input in filterable list mode ------------
func TestHandleAddWizardKeys_TypeCharAddsToSearchFilter(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
// Context step is filterable in list mode.
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "p", m.ui.addWizard.searchFilter)
}
// ---- handleAddWizardKeys: text input in text mode ----------------------
func TestHandleAddWizardKeys_TypeCharInTextMode(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeText
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("8")}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "8", m.ui.addWizard.textInput)
}
// ---- handleAddWizardEnter: StepSelectContext ----------------------------
func TestHandleAddWizardEnter_SelectContext(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Use a real discovery (may be nil cluster, but we only need the cmd to be returned).
ui.discovery = &k8s.Discovery{}
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepSelectContext
w.contexts = []string{"prod", "staging"}
w.cursor = 0
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, StepSelectNamespace, ui.addWizard.step)
assert.Equal(t, "prod", ui.addWizard.selectedContext)
ui.mu.RUnlock()
// A cmd is returned (loadNamespacesCmd) — we just verify it's not nil.
assert.NotNil(t, cmd)
}
func TestHandleAddWizardEnter_SelectContext_Loading_NoOp(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.loading = true
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.Nil(t, cmd)
}
// ---- handleAddWizardEnter: StepSelectNamespace --------------------------
func TestHandleAddWizardEnter_SelectNamespace(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
m.ui.addWizard.namespaces = []string{"default", "kube-system"}
m.ui.addWizard.cursor = 1 // kube-system
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepSelectResourceType, m.ui.addWizard.step)
assert.Equal(t, "kube-system", m.ui.addWizard.selectedNamespace)
}
// ---- handleAddWizardEnter: StepSelectResourceType ----------------------
func TestHandleAddWizardEnter_SelectResourceType_Service(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.discovery = &k8s.Discovery{}
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepSelectResourceType
w.selectedContext = "ctx"
w.selectedNamespace = "ns"
w.cursor = 2 // ResourceTypeService
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, StepEnterResource, ui.addWizard.step)
assert.Equal(t, ResourceTypeService, ui.addWizard.selectedResourceType)
ui.mu.RUnlock()
// A cmd (loadServicesCmd) is returned.
assert.NotNil(t, cmd)
}
func TestHandleAddWizardEnter_SelectResourceType_PodPrefix(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.discovery = &k8s.Discovery{}
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepSelectResourceType
w.selectedContext = "ctx"
w.selectedNamespace = "ns"
w.cursor = 0 // ResourceTypePodPrefix
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, StepEnterResource, ui.addWizard.step)
ui.mu.RUnlock()
// A cmd (loadPodsCmd) is returned.
assert.NotNil(t, cmd)
}
// ---- handleAddWizardEnter: StepEnterResource PodPrefix -----------------
func TestHandleAddWizardEnter_EnterResource_PodPrefix(t *testing.T) {
m := newModelWithWizard(StepEnterResource)
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "my-app"
m.ui.addWizard.pods = []k8s.PodInfo{{
Name: "my-app-abc",
Containers: []k8s.ContainerInfo{{Name: "main", Ports: []k8s.PortInfo{{Port: 8080}}}},
}}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
assert.Equal(t, "my-app", m.ui.addWizard.resourceValue)
}
func TestHandleAddWizardEnter_EnterResource_PodPrefix_EmptyInput_NoOp(t *testing.T) {
m := newModelWithWizard(StepEnterResource)
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = ""
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterResource, m.ui.addWizard.step) // should not advance
}
// ---- handleAddWizardEnter: StepEnterResource PodSelector ---------------
func TestHandleAddWizardEnter_EnterResource_PodSelector(t *testing.T) {
m := newModelWithWizard(StepEnterResource)
m.ui.addWizard.selectedResourceType = ResourceTypePodSelector
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "app=my"
m.ui.addWizard.matchingPods = []k8s.PodInfo{{Name: "my-pod"}}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
assert.Equal(t, "app=my", m.ui.addWizard.selector)
}
func TestHandleAddWizardEnter_EnterResource_PodSelector_NoMatchingPods_NoOp(t *testing.T) {
m := newModelWithWizard(StepEnterResource)
m.ui.addWizard.selectedResourceType = ResourceTypePodSelector
m.ui.addWizard.textInput = "app=my"
m.ui.addWizard.matchingPods = nil
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterResource, m.ui.addWizard.step)
}
// ---- handleAddWizardEnter: StepEnterResource Service -------------------
func TestHandleAddWizardEnter_EnterResource_Service(t *testing.T) {
m := newModelWithWizard(StepEnterResource)
m.ui.addWizard.selectedResourceType = ResourceTypeService
m.ui.addWizard.inputMode = InputModeList
m.ui.addWizard.services = []k8s.ServiceInfo{
{Name: "api-svc", Ports: []k8s.PortInfo{{Port: 80}}},
}
m.ui.addWizard.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
assert.Equal(t, "api-svc", m.ui.addWizard.resourceValue)
}
// ---- handleAddWizardEnter: StepEnterRemotePort -------------------------
func TestHandleAddWizardEnter_RemotePort_TextMode_ValidPort(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "8080"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step)
assert.Equal(t, 8080, m.ui.addWizard.remotePort)
}
func TestHandleAddWizardEnter_RemotePort_TextMode_InvalidPort(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "invalid"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
assert.NotNil(t, m.ui.addWizard.error)
}
func TestHandleAddWizardEnter_RemotePort_ListMode_SelectPort(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeList
m.ui.addWizard.detectedPorts = []k8s.PortInfo{
{Port: 80},
{Port: 8080, TargetPort: 9090},
}
m.ui.addWizard.cursor = 1 // 8080 → TargetPort 9090
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step)
assert.Equal(t, 9090, m.ui.addWizard.remotePort) // TargetPort used
}
func TestHandleAddWizardEnter_RemotePort_ListMode_ManualEntry(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeList
m.ui.addWizard.detectedPorts = []k8s.PortInfo{{Port: 80}}
m.ui.addWizard.cursor = 1 // "Manual entry" option
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, InputModeText, m.ui.addWizard.inputMode)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
}
// ---- handleAddWizardEnter: StepEnterLocalPort --------------------------
func TestHandleAddWizardEnter_LocalPort_ValidPort(t *testing.T) {
tmpDir := t.TempDir()
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.configPath = tmpDir + "/nonexistent.yaml"
w := newAddWizardState()
w.step = StepEnterLocalPort
w.textInput = "19876"
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
// loading set, cmd generated.
require.NotNil(t, cmd)
ui.mu.RLock()
assert.True(t, ui.addWizard.loading)
assert.Equal(t, 19876, ui.addWizard.localPort)
ui.mu.RUnlock()
}
func TestHandleAddWizardEnter_LocalPort_InvalidPort(t *testing.T) {
m := newModelWithWizard(StepEnterLocalPort)
m.ui.addWizard.textInput = "0" // invalid (must be > 0)
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, m.ui.addWizard.error)
}
// ---- handleAddWizardEnter: StepConfirmation ----------------------------
func TestHandleAddWizardEnter_Confirmation_FocusAlias_MoveToButtons(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
}
func TestHandleAddWizardEnter_Confirmation_CancelButton(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusButtons
m.ui.addWizard.cursor = 1 // Cancel
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
m.ui.mu.RLock()
assert.Nil(t, m.ui.addWizard)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
func TestHandleAddWizardEnter_Confirmation_PortNotAvailable_Error(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusButtons
m.ui.addWizard.cursor = 0 // Yes / Add
m.ui.addWizard.portAvailable = false
m.ui.addWizard.localPort = 8080
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, m.ui.addWizard.error)
assert.Equal(t, StepConfirmation, m.ui.addWizard.step) // should stay
}
func TestHandleAddWizardEnter_Confirmation_Save_NewForward(t *testing.T) {
mutator := newTempMutator(t)
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mutator = mutator
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepConfirmation
w.confirmationFocus = FocusButtons
w.cursor = 0 // Yes/Add
w.portAvailable = true
w.selectedContext = "ctx"
w.selectedNamespace = "ns"
w.resourceValue = "my-app"
w.selectedResourceType = ResourceTypePodPrefix
w.remotePort = 80
w.localPort = 18090
w.textInput = "my-alias"
w.httpLog = false
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
savedMsg, ok := msg.(ForwardSavedMsg)
require.True(t, ok)
assert.True(t, savedMsg.success)
}
func TestHandleAddWizardEnter_Confirmation_Save_NewForward_WithHTTPLog(t *testing.T) {
mutator := newTempMutator(t)
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mutator = mutator
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepConfirmation
w.confirmationFocus = FocusButtons
w.cursor = 0
w.portAvailable = true
w.selectedContext = "ctx"
w.selectedNamespace = "ns"
w.resourceValue = "my-app"
w.selectedResourceType = ResourceTypePodPrefix
w.remotePort = 80
w.localPort = 18091
w.httpLog = true
// Simulate having an existing HTTPLog config with advanced fields.
w.httpLogOriginal = &config.HTTPLogSpec{Enabled: false, IncludeHeaders: true, MaxBodySize: 4096}
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
savedMsg, ok := msg.(ForwardSavedMsg)
require.True(t, ok)
assert.True(t, savedMsg.success)
}
func TestHandleAddWizardEnter_Confirmation_Update_EditMode(t *testing.T) {
mutator := newTempMutator(t)
// Add the original forward first so the update has something to update.
origFwd := config.Forward{Resource: "pod/my-app", Port: 80, LocalPort: 18092}
require.NoError(t, mutator.AddForward("ctx", "ns", origFwd))
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mutator = mutator
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepConfirmation
w.confirmationFocus = FocusButtons
w.cursor = 0
w.portAvailable = true
w.isEditing = true
w.originalID = "ctx/ns/pod/my-app:18092"
w.selectedContext = "ctx"
w.selectedNamespace = "ns"
w.resourceValue = "my-app"
w.selectedResourceType = ResourceTypePodPrefix
w.remotePort = 80
w.localPort = 18092
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
savedMsg, ok := msg.(ForwardSavedMsg)
require.True(t, ok)
assert.True(t, savedMsg.success)
}
// ---- handleAddWizardEnter: StepSuccess ---------------------------------
func TestHandleAddWizardEnter_Success_AddAnother(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.discovery = &k8s.Discovery{}
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepSuccess
w.cursor = 0 // "Add another"
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
// A cmd (loadContextsCmd) must be returned.
assert.NotNil(t, cmd)
}
func TestHandleAddWizardEnter_Success_ReturnToMain(t *testing.T) {
m := newModelWithWizard(StepSuccess)
m.ui.addWizard.cursor = 1 // "Return to main"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd)
m.ui.mu.RLock()
assert.Nil(t, m.ui.addWizard)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
// ---- handleAddWizardKeys: selector validation on type -----------------
func TestHandleAddWizardKeys_SelectorValidation_OnChar(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.discovery = &k8s.Discovery{}
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepEnterResource
w.selectedResourceType = ResourceTypePodSelector
w.inputMode = InputModeText
w.textInput = "app=my"
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Typing a char in PodSelector step should return a validation cmd.
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("-")}
_, cmd := m.handleAddWizardKeys(keyMsg)
// A cmd is returned (validateSelectorCmd); we don't invoke it as it needs a real cluster.
assert.NotNil(t, cmd)
}
// ---- handleRemoveWizardKeys: Space, a, n keys --------------------------
func TestHandleRemoveWizardKeys_SpaceToggle(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "f1", Alias: "alpha"}, {ID: "f2", Alias: "beta"}},
selected: map[int]bool{},
cursor: 0,
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.True(t, ui.removeWizard.selected[0])
ui.mu.RUnlock()
// Toggle again to deselect.
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.False(t, ui.removeWizard.selected[0])
ui.mu.RUnlock()
}
func TestHandleRemoveWizardKeys_SelectAll(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "f1"}, {ID: "f2"}, {ID: "f3"}},
selected: map[int]bool{},
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.True(t, ui.removeWizard.selected[0])
assert.True(t, ui.removeWizard.selected[1])
assert.True(t, ui.removeWizard.selected[2])
ui.mu.RUnlock()
}
func TestHandleRemoveWizardKeys_SelectNone(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "f1"}, {ID: "f2"}},
selected: map[int]bool{0: true, 1: true},
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, len(ui.removeWizard.selected))
ui.mu.RUnlock()
}
func TestHandleRemoveWizardKeys_CtrlC_ExitsAlways(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "f1"}},
selected: map[int]bool{0: true},
confirming: true,
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Nil(t, ui.removeWizard)
assert.Equal(t, ViewModeMain, ui.viewMode)
ui.mu.RUnlock()
}
func TestHandleRemoveWizardKeys_Enter_NothingSelected_NoOp(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "f1"}},
selected: map[int]bool{},
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.Nil(t, cmd)
ui.mu.RLock()
assert.False(t, ui.removeWizard.confirming)
ui.mu.RUnlock()
}
func TestHandleRemoveWizardKeys_Enter_ShowConfirmation(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "f1"}},
selected: map[int]bool{0: true},
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.True(t, ui.removeWizard.confirming)
ui.mu.RUnlock()
}
func TestHandleRemoveWizardKeys_Enter_ConfirmNo(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "f1"}},
selected: map[int]bool{0: true},
confirming: true,
confirmCursor: 1, // "No"
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.Nil(t, cmd)
ui.mu.RLock()
assert.False(t, ui.removeWizard.confirming) // returned to selection
ui.mu.RUnlock()
}
func TestHandleRemoveWizardKeys_Navigation_InSelection(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{
forwards: []RemovableForward{{ID: "f1"}, {ID: "f2"}, {ID: "f3"}},
selected: map[int]bool{},
cursor: 0,
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 1, ui.removeWizard.cursor)
ui.mu.RUnlock()
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, ui.removeWizard.cursor)
ui.mu.RUnlock()
}
// ---- handleBenchmarkKeys: tab / text input / backspace -----------------
func TestHandleBenchmarkKeys_Tab(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
}
func TestHandleBenchmarkKeys_Tab_Wraps(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 3
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, 0, m.ui.benchmarkState.cursor)
}
func TestHandleBenchmarkKeys_Backspace(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 0
m.ui.benchmarkState.textInput = "/api"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, "/ap", m.ui.benchmarkState.textInput)
assert.Equal(t, "/ap", m.ui.benchmarkState.urlPath)
}
func TestHandleBenchmarkKeys_TypeChar_UpdatesField(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 1 // Method field
m.ui.benchmarkState.textInput = "GE"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("T")}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, "GET", m.ui.benchmarkState.method)
}
func TestHandleBenchmarkKeys_Enter_ResultsStep_ReturnsToMain(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepResults
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleBenchmarkKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
m.ui.mu.RLock()
assert.Nil(t, m.ui.benchmarkState)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
func TestHandleBenchmarkKeys_Enter_ConfigStep_StartsRunning(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepConfig
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleBenchmarkKeys(keyMsg)
require.NotNil(t, cmd)
m.ui.mu.RLock()
assert.Equal(t, BenchmarkStepRunning, m.ui.benchmarkState.step)
assert.True(t, m.ui.benchmarkState.running)
m.ui.mu.RUnlock()
}
// ---- applyBenchmarkTextInput / getBenchmarkFieldValue ------------------
func TestApplyBenchmarkTextInput_AllFields(t *testing.T) {
m := newModelWithBenchmark()
state := m.ui.benchmarkState
state.step = BenchmarkStepConfig
// URL path (cursor 0)
state.cursor = 0
state.textInput = "/new"
m.applyBenchmarkTextInput()
assert.Equal(t, "/new", state.urlPath)
// Method (cursor 1)
state.cursor = 1
state.textInput = "post"
m.applyBenchmarkTextInput()
assert.Equal(t, "POST", state.method)
// Concurrency (cursor 2)
state.cursor = 2
state.textInput = "5"
m.applyBenchmarkTextInput()
assert.Equal(t, 5, state.concurrency)
// Requests (cursor 3)
state.cursor = 3
state.textInput = "50"
m.applyBenchmarkTextInput()
assert.Equal(t, 50, state.requests)
}
func TestApplyBenchmarkTextInput_ConcurrencyCappedAtRequests(t *testing.T) {
m := newModelWithBenchmark()
state := m.ui.benchmarkState
state.requests = 10
state.cursor = 2
state.textInput = "999"
m.applyBenchmarkTextInput()
assert.Equal(t, 10, state.concurrency)
}
func TestGetBenchmarkFieldValue_AllFields(t *testing.T) {
m := newModelWithBenchmark()
state := m.ui.benchmarkState
state.urlPath = "/test"
state.method = "DELETE"
state.concurrency = 7
state.requests = 77
assert.Equal(t, "/test", m.getBenchmarkFieldValue(0))
assert.Equal(t, "DELETE", m.getBenchmarkFieldValue(1))
assert.Equal(t, "7", m.getBenchmarkFieldValue(2))
assert.Equal(t, "77", m.getBenchmarkFieldValue(3))
assert.Equal(t, "", m.getBenchmarkFieldValue(99))
}
func TestGetBenchmarkFieldValue_NilState(t *testing.T) {
m := newTestModel()
result := m.getBenchmarkFieldValue(0)
assert.Equal(t, "", result)
}
// ---- handleHTTPLogKeys: detail view keys --------------------------------
func TestHandleHTTPLogKeys_Detail_Esc(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.showingDetail = true
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test"}}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.showingDetail)
}
func TestHandleHTTPLogKeys_Detail_UpDown(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.showingDetail = true
m.ui.httpLogState.detailScroll = 5
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 4, m.ui.httpLogState.detailScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.detailScroll)
}
func TestHandleHTTPLogKeys_Detail_PgUpDown(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.showingDetail = true
m.ui.httpLogState.detailScroll = 25
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.detailScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 25, m.ui.httpLogState.detailScroll)
}
func TestHandleHTTPLogKeys_Detail_GoToTop(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.showingDetail = true
m.ui.httpLogState.detailScroll = 99
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.detailScroll)
}
// ---- handleHTTPLogKeys: list view keys ----------------------------------
func TestHandleHTTPLogKeys_Enter_ShowDetail(t *testing.T) {
m := newModelWithHTTPLog()
// StatusCode must be non-zero or getFilteredEntries will skip the entry.
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test", StatusCode: 200}}
m.ui.httpLogState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.showingDetail)
}
func TestHandleHTTPLogKeys_Navigate_UpDown(t *testing.T) {
m := newModelWithHTTPLog()
// StatusCode must be non-zero or getFilteredEntries will skip entries.
m.ui.httpLogState.entries = []HTTPLogEntry{
{Method: "GET", StatusCode: 200}, {Method: "POST", StatusCode: 200}, {Method: "PUT", StatusCode: 200},
}
m.ui.httpLogState.cursor = 1
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 1, m.ui.httpLogState.cursor)
}
func TestHandleHTTPLogKeys_Down_AtBottom_EnablesAutoScroll(t *testing.T) {
m := newModelWithHTTPLog()
// StatusCode must be non-zero or getFilteredEntries will skip entries.
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", StatusCode: 200}, {Method: "POST", StatusCode: 200}}
m.ui.httpLogState.cursor = 0
m.ui.httpLogState.autoScroll = false
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.autoScroll)
}
func TestHandleHTTPLogKeys_GoToTop_G(t *testing.T) {
m := newModelWithHTTPLog()
// StatusCode must be non-zero or getFilteredEntries will skip entries.
m.ui.httpLogState.entries = []HTTPLogEntry{
{Method: "GET", StatusCode: 200}, {Method: "POST", StatusCode: 200}, {Method: "PUT", StatusCode: 200},
}
m.ui.httpLogState.cursor = 2
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.cursor)
assert.False(t, m.ui.httpLogState.autoScroll)
}
func TestHandleHTTPLogKeys_GoToBottom_CapitalG(t *testing.T) {
m := newModelWithHTTPLog()
// StatusCode must be non-zero or getFilteredEntries will skip entries.
m.ui.httpLogState.entries = []HTTPLogEntry{
{Method: "GET", StatusCode: 200}, {Method: "POST", StatusCode: 200}, {Method: "PUT", StatusCode: 200},
}
m.ui.httpLogState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 2, m.ui.httpLogState.cursor)
assert.True(t, m.ui.httpLogState.autoScroll)
}
func TestHandleHTTPLogKeys_ToggleAutoScroll(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.autoScroll = false
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.autoScroll)
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.autoScroll)
}
func TestHandleHTTPLogKeys_PgUpDown_List(t *testing.T) {
m := newModelWithHTTPLog()
entries := make([]HTTPLogEntry, 30)
for i := range entries {
// StatusCode must be non-zero or getFilteredEntries will skip entries.
entries[i] = HTTPLogEntry{Method: "GET", StatusCode: 200}
}
m.ui.httpLogState.entries = entries
m.ui.httpLogState.cursor = 25
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.cursor)
assert.False(t, m.ui.httpLogState.autoScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
m.handleHTTPLogKeys(keyMsg)
// PgDown +20 from 5 = 25; index 25 is in range (30 items, indices 0-29).
assert.Equal(t, 25, m.ui.httpLogState.cursor)
}
// ---- handleHTTPLogKeys: filter active (text input mode) ----------------
func TestHandleHTTPLogKeys_FilterActive_Typing(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "te"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, "tes", m.ui.httpLogState.filterText)
}
func TestHandleHTTPLogKeys_FilterActive_Backspace(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "tes"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, "te", m.ui.httpLogState.filterText)
}
func TestHandleHTTPLogKeys_FilterActive_EscClearsFilter(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "test"
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.filterActive)
assert.Empty(t, m.ui.httpLogState.filterText)
}
func TestHandleHTTPLogKeys_FilterActive_EnterConfirms(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "api"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.filterActive)
assert.Equal(t, "api", m.ui.httpLogState.filterText)
}
// ---- handleHTTPLogEntry: response merging, capping ---------------------
func TestHandleHTTPLogEntry_ResponseMerging(t *testing.T) {
m := newModelWithHTTPLog()
reqID := "req-123"
// Add a request entry first.
requestEntry := HTTPLogEntry{
Direction: "request",
Method: "GET",
Path: "/api/test",
RequestID: reqID,
}
m.ui.httpLogState.entries = []HTTPLogEntry{requestEntry}
// Now handle the response.
responseEntry := HTTPLogEntry{
Direction: "response",
RequestID: reqID,
StatusCode: 200,
LatencyMs: 50,
BodySize: 100,
ResponseBody: "ok",
}
m.handleHTTPLogEntry(HTTPLogEntryMsg{Entry: responseEntry})
// Should be merged into the single request entry.
assert.Len(t, m.ui.httpLogState.entries, 1)
assert.Equal(t, 200, m.ui.httpLogState.entries[0].StatusCode)
assert.Equal(t, "response", m.ui.httpLogState.entries[0].Direction)
}
func TestHandleHTTPLogEntry_UnmatchedResponse_Appended(t *testing.T) {
m := newModelWithHTTPLog()
responseEntry := HTTPLogEntry{
Direction: "response",
RequestID: "no-match-id",
StatusCode: 404,
}
m.handleHTTPLogEntry(HTTPLogEntryMsg{Entry: responseEntry})
assert.Len(t, m.ui.httpLogState.entries, 1)
}
func TestHandleHTTPLogEntry_CapsAt10000(t *testing.T) {
m := newModelWithHTTPLog()
// Fill to 10000.
entries := make([]HTTPLogEntry, 10000)
for i := range entries {
entries[i] = HTTPLogEntry{Method: "GET", Path: "/old"}
}
m.ui.httpLogState.entries = entries
// Add one more.
m.handleHTTPLogEntry(HTTPLogEntryMsg{Entry: HTTPLogEntry{Method: "POST", Path: "/new"}})
assert.Len(t, m.ui.httpLogState.entries, 10000)
// The newest entry should be at the end.
last := m.ui.httpLogState.entries[len(m.ui.httpLogState.entries)-1]
assert.Equal(t, "/new", last.Path)
}
// ---- handleContextsLoaded: without discovery (nil) ---------------------
// TestHandleContextsLoaded_NilDiscovery_UsesMessagesDirectly verifies that
// when discovery is nil (no kubeconfig), the contexts from the message are
// used as-is without reordering.
func TestHandleContextsLoaded_NilDiscovery_UsesMessagesDirectly(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// No discovery set — contexts must still be loaded from the message.
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 := ContextsLoadedMsg{
contexts: []string{"default", "production", "staging"},
}
m.handleContextsLoaded(msg)
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.Contains(t, ui.addWizard.contexts, "staging")
assert.Contains(t, ui.addWizard.contexts, "default")
}
// ---- handlePodsLoaded: edit mode port detection ------------------------
func TestHandlePodsLoaded_EditMode_DetectsPorts(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepEnterRemotePort
w.isEditing = true
w.remotePort = 8080
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
pods := []k8s.PodInfo{
{
Name: "my-pod",
Containers: []k8s.ContainerInfo{
{Name: "main", Ports: []k8s.PortInfo{{Port: 8080}}},
},
},
}
m.handlePodsLoaded(PodsLoadedMsg{pods: pods})
ui.mu.RLock()
assert.NotEmpty(t, ui.addWizard.detectedPorts)
ui.mu.RUnlock()
}
// ---- handleServicesLoaded: edit mode port detection --------------------
func TestHandleServicesLoaded_EditMode_DetectsPorts(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
w := newAddWizardState()
w.step = StepEnterRemotePort
w.isEditing = true
w.resourceValue = "api-svc"
w.remotePort = 80
ui.addWizard = w
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
services := []k8s.ServiceInfo{
{Name: "api-svc", Ports: []k8s.PortInfo{{Port: 80}}},
}
m.handleServicesLoaded(ServicesLoadedMsg{services: services})
ui.mu.RLock()
assert.NotEmpty(t, ui.addWizard.detectedPorts)
ui.mu.RUnlock()
}
// ---- handleForwardSaved: error path ------------------------------------
func TestHandleForwardSaved_Error(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: false, err: assert.AnError}
m.handleForwardSaved(msg)
// On failure, step stays at StepConfirmation (not moved to StepSuccess).
// loading is cleared and error is populated.
ui.mu.RLock()
assert.Equal(t, StepConfirmation, ui.addWizard.step)
assert.NotNil(t, ui.addWizard.error)
assert.False(t, ui.addWizard.loading)
ui.mu.RUnlock()
}
// ---- handleSelectorValidated: invalid path ----------------------------
func TestHandleSelectorValidated_Invalid(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 := SelectorValidatedMsg{valid: false, err: assert.AnError}
m.handleSelectorValidated(msg)
ui.mu.RLock()
assert.Nil(t, ui.addWizard.matchingPods)
assert.NotNil(t, ui.addWizard.error)
ui.mu.RUnlock()
}
// ---- isFilterableStep --------------------------------------------------
func TestIsFilterableStep(t *testing.T) {
assert.True(t, isFilterableStep(StepSelectContext))
assert.True(t, isFilterableStep(StepSelectNamespace))
assert.True(t, isFilterableStep(StepEnterResource))
assert.False(t, isFilterableStep(StepEnterRemotePort))
assert.False(t, isFilterableStep(StepEnterLocalPort))
assert.False(t, isFilterableStep(StepConfirmation))
assert.False(t, isFilterableStep(StepSuccess))
}
// ---- clearCopyMessageMsg handling --------------------------------------
func TestModel_Update_ClearCopyMessage(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.httpLogState.copyMessage = "Copied!"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
newM, _ := m.Update(clearCopyMessageMsg{})
updatedUI := newM.(model).ui
updatedUI.mu.RLock()
assert.Empty(t, updatedUI.httpLogState.copyMessage)
updatedUI.mu.RUnlock()
}
// ---- ForwardAddMsg / ForwardUpdateMsg / ForwardRemoveMsg routing --------
func TestModel_Update_ForwardMessages_NoPanic(t *testing.T) {
m := newTestModelWithForward()
msgs := []tea.Msg{
ForwardAddMsg{ID: "x", Forward: &ForwardStatus{}},
ForwardUpdateMsg{ID: "test-id", Status: "Active"},
ForwardErrorMsg{ID: "test-id", Error: "boom"},
ForwardRemoveMsg{ID: "test-id"},
}
for _, msg := range msgs {
assert.NotPanics(t, func() {
_, _ = m.Update(msg)
})
}
}
// ---- SetHTTPLogSubscriber -----------------------------------------------
func TestBubbleTeaUI_SetHTTPLogSubscriber(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
mock := NewMockHTTPLogSubscriber()
ui.SetHTTPLogSubscriber(mock.GetSubscriberFunc())
ui.mu.RLock()
assert.NotNil(t, ui.httpLogSubscriber)
ui.mu.RUnlock()
}
// ---- ResourceType.String / Description ---------------------------------
func TestResourceType_StringAndDescription(t *testing.T) {
tests := []struct {
expStr string
expDescContains string
rt ResourceType
}{
{"Pod (by name prefix)", "specific", ResourceTypePodPrefix},
{"Pod (by label selector)", "survives", ResourceTypePodSelector},
{"Service", "stable", ResourceTypeService},
{"Unknown", "", ResourceType(99)},
}
for _, tt := range tests {
t.Run(tt.expStr, func(t *testing.T) {
assert.Equal(t, tt.expStr, tt.rt.String())
desc := tt.rt.Description()
if tt.expDescContains != "" {
assert.Contains(t, desc, tt.expDescContains)
}
})
}
}
// ---- wizard_commands: saveForwardCmd / removeForwardsCmd / removeForwardByIDCmd
// These use *config.Mutator (concrete type), so we create a real mutator
// backed by a temp config file.
func newTempMutator(t *testing.T) *config.Mutator {
t.Helper()
tmpDir := t.TempDir()
cfgPath := tmpDir + "/.kportal.yaml"
if err := os.WriteFile(cfgPath, []byte("contexts: []\n"), 0o600); err != nil {
t.Fatalf("create temp config: %v", err)
}
return config.NewMutator(cfgPath)
}
func TestSaveForwardCmd_Success(t *testing.T) {
mutator := newTempMutator(t)
fwd := config.Forward{Resource: "pod/app", Port: 80, LocalPort: 18081}
cmd := saveForwardCmd(mutator, "ctx", "ns", fwd)
msg := cmd()
savedMsg, ok := msg.(ForwardSavedMsg)
require.True(t, ok)
assert.True(t, savedMsg.success)
}
func TestRemoveForwardsCmd_Success(t *testing.T) {
mutator := newTempMutator(t)
// Add two forwards: removing one must leave the context non-empty to pass validation.
fwd := config.Forward{Resource: "pod/app", Port: 80, LocalPort: 18082}
fwdKeep := config.Forward{Resource: "pod/app2", Port: 80, LocalPort: 18090}
require.NoError(t, mutator.AddForward("ctx", "ns", fwd))
require.NoError(t, mutator.AddForward("ctx", "ns", fwdKeep))
forwards := []RemovableForward{{ID: "ctx/ns/pod/app:18082", Alias: "app"}}
cmd := removeForwardsCmd(mutator, forwards)
msg := cmd()
removedMsg, ok := msg.(ForwardsRemovedMsg)
require.True(t, ok)
assert.True(t, removedMsg.success)
assert.Equal(t, 1, removedMsg.count)
}
func TestRemoveForwardByIDCmd_Success(t *testing.T) {
mutator := newTempMutator(t)
// Add two forwards: removing one must leave the context non-empty to pass validation.
fwd := config.Forward{Resource: "pod/app", Port: 80, LocalPort: 18083}
fwdKeep := config.Forward{Resource: "pod/app2", Port: 80, LocalPort: 18091}
require.NoError(t, mutator.AddForward("ctx", "ns", fwd))
require.NoError(t, mutator.AddForward("ctx", "ns", fwdKeep))
id := "ctx/ns/pod/app:18083"
cmd := removeForwardByIDCmd(mutator, id)
msg := cmd()
removedMsg, ok := msg.(ForwardsRemovedMsg)
require.True(t, ok)
assert.True(t, removedMsg.success)
}