mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
* feat: add --context to forward only selected contexts
kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.
--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:
kportal --context development-team-a
kportal --context team-a,team-b -headless
kportal --context team-a --context team-b
An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.
Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.
Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.
Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.
* fix(config): keep the running config when no selected context remains
A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.
LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.
Refs #80
* fix(ui): apply new table columns after a successful SIGHUP reload
The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.
Refs #80
* style: gofmt run_test.go
---------
Co-authored-by: André Cruz <[email protected]>
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)
|
|
}
|