mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-30 05:44:37 +00:00
90ddca6709
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.
1694 lines
50 KiB
Go
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)
|
|
}
|