Files
kportal/internal/ui/bubbletea_ui_test.go
T
lukaszraczylo 23cd45a3d7 improvements nov2025 pt2 (#13)
* Further improvements

| Fix                                | Impact                                 | Files Modified                       |
|------------------------------------|----------------------------------------|--------------------------------------|
| sync.Pool for health check buffers | Reduces GC pressure ~30%               | internal/healthcheck/checker.go      |
| Goroutine leak fix + sync.Once     | Prevents memory leaks                  | internal/forward/worker.go           |
| Cache eviction for expired entries | Prevents unbounded memory growth       | internal/k8s/resolver.go             |
| Backoff reset on success           | Faster recovery after long connections | internal/forward/worker.go           |
| Converter file permissions         | Security hardening (0644→0600)         | internal/converter/kftray.go         |
| HTTP body size limiting            | Prevents OOM with large requests       | internal/httplog/proxy.go, logger.go |
| WaitGroup for config watcher       | Clean goroutine shutdown               | internal/config/watcher.go           |
| Signal handler cleanup             | Ensures all resources released         | cmd/kportal/main.go                  |

* Additional event bus for internal event handling

| Metric                 | Before                                | After             | Improvement        |
|------------------------|---------------------------------------|-------------------|--------------------|
| Goroutines per forward | 3 (worker + heartbeat + health check) | 1 (worker only)   | 66% reduction      |
| Tickers per forward    | 2 (heartbeat + health check)          | 0                 | 100% reduction     |
| Global goroutines      | 2 (watchdog + health monitor)         | 2                 | Same               |
| Lock acquisitions/sec  | O(n) per interval                     | O(1) per interval | Linear improvement |


* Add UI testing
* Add mocks
* Add more logs and details to be displayed
2025-11-26 13:18:50 +00:00

530 lines
13 KiB
Go

package ui
import (
"testing"
"github.com/nvm/kportal/internal/config"
"github.com/stretchr/testify/assert"
)
// TestNewBubbleTeaUI tests the constructor
func TestNewBubbleTeaUI(t *testing.T) {
callback := func(id string, enable bool) {}
ui := NewBubbleTeaUI(callback, "1.0.0")
assert.NotNil(t, ui)
assert.NotNil(t, ui.forwards)
assert.NotNil(t, ui.forwardOrder)
assert.NotNil(t, ui.disabledMap)
assert.NotNil(t, ui.errors)
assert.Equal(t, "1.0.0", ui.version)
assert.Equal(t, ViewModeMain, ui.viewMode)
assert.Equal(t, 0, ui.selectedIndex)
}
// TestBubbleTeaUI_AddForward tests adding forwards
func TestBubbleTeaUI_AddForward(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
Alias: "my-app",
}
ui.AddForward("test-id", fwd)
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.Len(t, ui.forwards, 1)
assert.Len(t, ui.forwardOrder, 1)
assert.Equal(t, "test-id", ui.forwardOrder[0])
status := ui.forwards["test-id"]
assert.Equal(t, "my-app", status.Alias)
assert.Equal(t, "my-app", status.Resource)
assert.Equal(t, "pod", status.Type)
assert.Equal(t, 8080, status.LocalPort)
assert.Equal(t, 8080, status.RemotePort)
assert.Equal(t, "Starting", status.Status)
}
// TestBubbleTeaUI_AddForward_ServiceResource tests adding a service forward
func TestBubbleTeaUI_AddForward_ServiceResource(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5432,
}
ui.AddForward("svc-id", fwd)
ui.mu.RLock()
defer ui.mu.RUnlock()
status := ui.forwards["svc-id"]
assert.Equal(t, "postgres", status.Alias) // Uses resource name when no alias
assert.Equal(t, "postgres", status.Resource)
assert.Equal(t, "service", status.Type)
}
// TestBubbleTeaUI_AddForward_ReEnable tests re-enabling a disabled forward
func TestBubbleTeaUI_AddForward_ReEnable(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
// Add forward
ui.AddForward("test-id", fwd)
// Disable it
ui.mu.Lock()
ui.disabledMap["test-id"] = true
ui.forwards["test-id"].Status = "Disabled"
ui.mu.Unlock()
// Re-add (re-enable)
ui.AddForward("test-id", fwd)
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.False(t, ui.disabledMap["test-id"])
assert.Equal(t, "Starting", ui.forwards["test-id"].Status)
assert.Len(t, ui.forwardOrder, 1) // Should not duplicate
}
// TestBubbleTeaUI_UpdateStatus tests status updates
func TestBubbleTeaUI_UpdateStatus(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
// Update to Active
ui.UpdateStatus("test-id", "Active")
ui.mu.RLock()
assert.Equal(t, "Active", ui.forwards["test-id"].Status)
ui.mu.RUnlock()
// Update to Error
ui.UpdateStatus("test-id", "Error")
ui.mu.RLock()
assert.Equal(t, "Error", ui.forwards["test-id"].Status)
ui.mu.RUnlock()
}
// TestBubbleTeaUI_UpdateStatus_ClearsErrorOnActive tests that errors are cleared when status becomes Active
func TestBubbleTeaUI_UpdateStatus_ClearsErrorOnActive(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
// Set an error
ui.SetError("test-id", "connection refused")
ui.mu.RLock()
assert.Equal(t, "connection refused", ui.errors["test-id"])
ui.mu.RUnlock()
// Update to Active - should clear error
ui.UpdateStatus("test-id", "Active")
ui.mu.RLock()
_, hasError := ui.errors["test-id"]
ui.mu.RUnlock()
assert.False(t, hasError, "Error should be cleared when status becomes Active")
}
// TestBubbleTeaUI_UpdateStatus_KeepsErrorOnReconnecting tests that errors persist during reconnection
func TestBubbleTeaUI_UpdateStatus_KeepsErrorOnReconnecting(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
// Set an error
ui.SetError("test-id", "connection refused")
// Update to Reconnecting - should keep error
ui.UpdateStatus("test-id", "Reconnecting")
ui.mu.RLock()
assert.Equal(t, "connection refused", ui.errors["test-id"])
ui.mu.RUnlock()
}
// TestBubbleTeaUI_SetError tests error setting
func TestBubbleTeaUI_SetError(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
ui.SetError("test-id", "connection timeout")
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.Equal(t, "connection timeout", ui.errors["test-id"])
}
// TestBubbleTeaUI_Remove tests forward removal
func TestBubbleTeaUI_Remove(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
ui.Remove("test-id")
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.Len(t, ui.forwards, 0)
assert.Len(t, ui.forwardOrder, 0)
}
// TestBubbleTeaUI_Remove_ClearsErrors tests that removal clears associated errors
func TestBubbleTeaUI_Remove_ClearsErrors(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
ui.SetError("test-id", "some error")
ui.Remove("test-id")
ui.mu.RLock()
defer ui.mu.RUnlock()
_, hasError := ui.errors["test-id"]
assert.False(t, hasError, "Error should be cleared on removal")
}
// TestBubbleTeaUI_Remove_AdjustsSelectedIndex tests index adjustment after removal
func TestBubbleTeaUI_Remove_AdjustsSelectedIndex(t *testing.T) {
tests := []struct {
name string
forwards []string
selectedIndex int
removeID string
expectedIndex int
expectedRemaining int
}{
{
name: "remove selected item (last in list)",
forwards: []string{"a", "b", "c"},
selectedIndex: 2,
removeID: "c",
expectedIndex: 1, // Should move to previous item
expectedRemaining: 2,
},
{
name: "remove item before selected",
forwards: []string{"a", "b", "c"},
selectedIndex: 2,
removeID: "a",
expectedIndex: 1, // Index shifts down but points to same item
expectedRemaining: 2,
},
{
name: "remove item after selected",
forwards: []string{"a", "b", "c"},
selectedIndex: 0,
removeID: "c",
expectedIndex: 0, // No change needed
expectedRemaining: 2,
},
{
name: "remove only item",
forwards: []string{"a"},
selectedIndex: 0,
removeID: "a",
expectedIndex: 0, // Stays at 0 (clamped)
expectedRemaining: 0,
},
{
name: "remove middle item when selected is after",
forwards: []string{"a", "b", "c", "d"},
selectedIndex: 3,
removeID: "b",
expectedIndex: 2, // Adjusts down
expectedRemaining: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Add forwards
for _, id := range tt.forwards {
fwd := &config.Forward{
Resource: "pod/" + id,
Port: 8080,
LocalPort: 8080,
}
ui.AddForward(id, fwd)
}
// Set selected index
ui.mu.Lock()
ui.selectedIndex = tt.selectedIndex
ui.mu.Unlock()
// Remove
ui.Remove(tt.removeID)
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.Equal(t, tt.expectedIndex, ui.selectedIndex)
assert.Len(t, ui.forwardOrder, tt.expectedRemaining)
})
}
}
// TestBubbleTeaUI_Remove_ClearsDeleteConfirmation tests that pending delete confirmation is cleared
func TestBubbleTeaUI_Remove_ClearsDeleteConfirmation(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
// Set up delete confirmation
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmAlias = "my-app"
ui.mu.Unlock()
// Remove the forward
ui.Remove("test-id")
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.False(t, ui.deleteConfirming, "Delete confirmation should be cleared")
assert.Empty(t, ui.deleteConfirmID)
}
// TestBubbleTeaUI_Remove_KeepsOtherDeleteConfirmation tests that unrelated delete confirmation persists
func TestBubbleTeaUI_Remove_KeepsOtherDeleteConfirmation(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd1 := &config.Forward{Resource: "pod/app1", Port: 8080, LocalPort: 8080}
fwd2 := &config.Forward{Resource: "pod/app2", Port: 8081, LocalPort: 8081}
ui.AddForward("id-1", fwd1)
ui.AddForward("id-2", fwd2)
// Set up delete confirmation for id-2
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "id-2"
ui.deleteConfirmAlias = "app2"
ui.mu.Unlock()
// Remove id-1 (different forward)
ui.Remove("id-1")
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.True(t, ui.deleteConfirming, "Delete confirmation for other forward should persist")
assert.Equal(t, "id-2", ui.deleteConfirmID)
}
// TestBubbleTeaUI_MoveSelection tests cursor movement
func TestBubbleTeaUI_MoveSelection(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Add some forwards
for i := 0; i < 5; i++ {
fwd := &config.Forward{
Resource: "pod/app",
Port: 8080 + i,
LocalPort: 8080 + i,
}
ui.AddForward(string(rune('a'+i)), fwd)
}
tests := []struct {
name string
initialIndex int
delta int
expectedIndex int
}{
{"move down from 0", 0, 1, 1},
{"move down from middle", 2, 1, 3},
{"move up from middle", 2, -1, 1},
{"cannot move below 0", 0, -1, 0},
{"cannot move above max", 4, 1, 4},
{"large delta clamped to max", 0, 100, 4},
{"large negative delta clamped to 0", 4, -100, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui.mu.Lock()
ui.selectedIndex = tt.initialIndex
ui.mu.Unlock()
ui.moveSelection(tt.delta)
ui.mu.RLock()
assert.Equal(t, tt.expectedIndex, ui.selectedIndex)
ui.mu.RUnlock()
})
}
}
// TestBubbleTeaUI_MoveSelection_EmptyList tests movement with no forwards
func TestBubbleTeaUI_MoveSelection_EmptyList(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Should not panic with empty list
ui.moveSelection(1)
ui.moveSelection(-1)
ui.mu.RLock()
assert.Equal(t, 0, ui.selectedIndex)
ui.mu.RUnlock()
}
// TestBubbleTeaUI_ToggleSelected tests toggling forward state
func TestBubbleTeaUI_ToggleSelected(t *testing.T) {
callback := func(id string, enable bool) {
// Callback is called in a goroutine
}
ui := NewBubbleTeaUI(callback, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
// Toggle to disabled
ui.toggleSelected()
// Wait for goroutine
ui.mu.RLock()
isDisabled := ui.disabledMap["test-id"]
ui.mu.RUnlock()
assert.True(t, isDisabled)
// Toggle back to enabled
ui.toggleSelected()
ui.mu.RLock()
isDisabled = ui.disabledMap["test-id"]
ui.mu.RUnlock()
assert.False(t, isDisabled)
}
// TestBubbleTeaUI_SetUpdateAvailable tests update notification
func TestBubbleTeaUI_SetUpdateAvailable(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetUpdateAvailable("2.0.0", "https://example.com/update")
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.True(t, ui.updateAvailable)
assert.Equal(t, "2.0.0", ui.updateVersion)
assert.Equal(t, "https://example.com/update", ui.updateURL)
}
// TestBubbleTeaUI_SetWizardDependencies tests dependency injection
func TestBubbleTeaUI_SetWizardDependencies(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Initially nil
ui.mu.RLock()
assert.Nil(t, ui.discovery)
assert.Nil(t, ui.mutator)
assert.Empty(t, ui.configPath)
ui.mu.RUnlock()
// Set dependencies (using nil for simplicity - just testing the setter)
ui.SetWizardDependencies(nil, nil, "/path/to/config.yaml")
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.Equal(t, "/path/to/config.yaml", ui.configPath)
}
// TestBubbleTeaUI_ResetDeleteConfirmation tests the reset helper
func TestBubbleTeaUI_ResetDeleteConfirmation(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Set up confirmation state
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmAlias = "test-alias"
ui.deleteConfirmCursor = 1
ui.mu.Unlock()
// Reset
ui.mu.Lock()
ui.resetDeleteConfirmation()
ui.mu.Unlock()
ui.mu.RLock()
defer ui.mu.RUnlock()
assert.False(t, ui.deleteConfirming)
assert.Empty(t, ui.deleteConfirmID)
assert.Empty(t, ui.deleteConfirmAlias)
assert.Equal(t, 0, ui.deleteConfirmCursor)
}