mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-08 06:35:10 +00:00
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
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestNewBenchmarkState tests the constructor
|
||||
func TestNewBenchmarkState(t *testing.T) {
|
||||
state := newBenchmarkState("forward-123", "my-service", 8080)
|
||||
|
||||
assert.Equal(t, "forward-123", state.forwardID)
|
||||
assert.Equal(t, "my-service", state.forwardAlias)
|
||||
assert.Equal(t, 8080, state.localPort)
|
||||
assert.Equal(t, BenchmarkStepConfig, state.step)
|
||||
assert.Equal(t, "/", state.urlPath)
|
||||
assert.Equal(t, "GET", state.method)
|
||||
assert.Equal(t, 10, state.concurrency)
|
||||
assert.Equal(t, 100, state.requests)
|
||||
assert.Equal(t, 0, state.cursor)
|
||||
assert.False(t, state.running)
|
||||
assert.Nil(t, state.results)
|
||||
assert.Nil(t, state.error)
|
||||
assert.Nil(t, state.cancelFunc)
|
||||
}
|
||||
|
||||
// TestBenchmarkState_StepTransitions tests step progression
|
||||
func TestBenchmarkState_StepTransitions(t *testing.T) {
|
||||
state := newBenchmarkState("fwd", "alias", 8080)
|
||||
|
||||
// Initial state
|
||||
assert.Equal(t, BenchmarkStepConfig, state.step)
|
||||
|
||||
// Move to running
|
||||
state.step = BenchmarkStepRunning
|
||||
state.running = true
|
||||
assert.Equal(t, BenchmarkStepRunning, state.step)
|
||||
assert.True(t, state.running)
|
||||
|
||||
// Move to results
|
||||
state.step = BenchmarkStepResults
|
||||
state.running = false
|
||||
assert.Equal(t, BenchmarkStepResults, state.step)
|
||||
assert.False(t, state.running)
|
||||
}
|
||||
|
||||
// TestBenchmarkState_ProgressTracking tests progress updates
|
||||
func TestBenchmarkState_ProgressTracking(t *testing.T) {
|
||||
state := newBenchmarkState("fwd", "alias", 8080)
|
||||
state.step = BenchmarkStepRunning
|
||||
state.running = true
|
||||
state.total = 100
|
||||
|
||||
// Simulate progress updates
|
||||
updates := []struct {
|
||||
progress int
|
||||
total int
|
||||
}{
|
||||
{10, 100},
|
||||
{50, 100},
|
||||
{75, 100},
|
||||
{100, 100},
|
||||
}
|
||||
|
||||
for _, u := range updates {
|
||||
state.progress = u.progress
|
||||
state.total = u.total
|
||||
assert.Equal(t, u.progress, state.progress)
|
||||
assert.Equal(t, u.total, state.total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBenchmarkState_CancelFunc tests cancel function handling
|
||||
func TestBenchmarkState_CancelFunc(t *testing.T) {
|
||||
state := newBenchmarkState("fwd", "alias", 8080)
|
||||
|
||||
cancelled := false
|
||||
state.cancelFunc = func() {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
assert.NotNil(t, state.cancelFunc)
|
||||
|
||||
// Call cancel
|
||||
state.cancelFunc()
|
||||
assert.True(t, cancelled)
|
||||
}
|
||||
|
||||
// TestBenchmarkState_Results tests result storage
|
||||
func TestBenchmarkState_Results(t *testing.T) {
|
||||
state := newBenchmarkState("fwd", "alias", 8080)
|
||||
|
||||
results := &BenchmarkResults{
|
||||
TotalRequests: 100,
|
||||
Successful: 95,
|
||||
Failed: 5,
|
||||
MinLatency: 10.5,
|
||||
MaxLatency: 250.0,
|
||||
AvgLatency: 45.2,
|
||||
P50Latency: 40.0,
|
||||
P95Latency: 120.0,
|
||||
P99Latency: 200.0,
|
||||
Throughput: 150.5,
|
||||
BytesRead: 1024000,
|
||||
StatusCodes: map[int]int{
|
||||
200: 90,
|
||||
201: 5,
|
||||
500: 5,
|
||||
},
|
||||
}
|
||||
|
||||
state.results = results
|
||||
state.step = BenchmarkStepResults
|
||||
|
||||
assert.Equal(t, 100, state.results.TotalRequests)
|
||||
assert.Equal(t, 95, state.results.Successful)
|
||||
assert.Equal(t, 5, state.results.Failed)
|
||||
assert.Equal(t, 45.2, state.results.AvgLatency)
|
||||
assert.Equal(t, 150.5, state.results.Throughput)
|
||||
}
|
||||
|
||||
// TestBenchmarkState_Error tests error handling
|
||||
func TestBenchmarkState_Error(t *testing.T) {
|
||||
state := newBenchmarkState("fwd", "alias", 8080)
|
||||
|
||||
assert.Nil(t, state.error)
|
||||
|
||||
// Simulate error
|
||||
state.error = assert.AnError
|
||||
state.step = BenchmarkStepResults
|
||||
state.running = false
|
||||
|
||||
assert.NotNil(t, state.error)
|
||||
assert.Nil(t, state.results)
|
||||
}
|
||||
|
||||
// TestBenchmarkState_ConfigFields tests configuration field updates
|
||||
func TestBenchmarkState_ConfigFields(t *testing.T) {
|
||||
state := newBenchmarkState("fwd", "alias", 8080)
|
||||
|
||||
// Update URL path
|
||||
state.urlPath = "/api/v1/health"
|
||||
assert.Equal(t, "/api/v1/health", state.urlPath)
|
||||
|
||||
// Update method
|
||||
state.method = "POST"
|
||||
assert.Equal(t, "POST", state.method)
|
||||
|
||||
// Update concurrency
|
||||
state.concurrency = 50
|
||||
assert.Equal(t, 50, state.concurrency)
|
||||
|
||||
// Update requests
|
||||
state.requests = 1000
|
||||
assert.Equal(t, 1000, state.requests)
|
||||
}
|
||||
|
||||
// TestBenchmarkState_CursorBounds tests cursor navigation bounds
|
||||
func TestBenchmarkState_CursorBounds(t *testing.T) {
|
||||
state := newBenchmarkState("fwd", "alias", 8080)
|
||||
|
||||
// There are 4 config fields (0-3)
|
||||
tests := []struct {
|
||||
name string
|
||||
cursor int
|
||||
expected int
|
||||
}{
|
||||
{"first field", 0, 0},
|
||||
{"second field", 1, 1},
|
||||
{"third field", 2, 2},
|
||||
{"fourth field", 3, 3},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
state.cursor = tt.cursor
|
||||
assert.Equal(t, tt.expected, state.cursor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBenchmarkState_ProgressChannel tests progress channel handling
|
||||
func TestBenchmarkState_ProgressChannel(t *testing.T) {
|
||||
state := newBenchmarkState("fwd", "alias", 8080)
|
||||
|
||||
// Create a progress channel
|
||||
state.progressCh = make(chan BenchmarkProgressMsg, 10)
|
||||
|
||||
// Send some progress
|
||||
state.progressCh <- BenchmarkProgressMsg{
|
||||
ForwardID: "fwd",
|
||||
Completed: 50,
|
||||
Total: 100,
|
||||
}
|
||||
|
||||
// Receive and verify
|
||||
msg := <-state.progressCh
|
||||
assert.Equal(t, "fwd", msg.ForwardID)
|
||||
assert.Equal(t, 50, msg.Completed)
|
||||
assert.Equal(t, 100, msg.Total)
|
||||
|
||||
// Close channel
|
||||
close(state.progressCh)
|
||||
}
|
||||
|
||||
// TestBenchmarkStepValues tests step constants
|
||||
func TestBenchmarkStepValues(t *testing.T) {
|
||||
assert.Equal(t, BenchmarkStep(0), BenchmarkStepConfig)
|
||||
assert.Equal(t, BenchmarkStep(1), BenchmarkStepRunning)
|
||||
assert.Equal(t, BenchmarkStep(2), BenchmarkStepResults)
|
||||
}
|
||||
|
||||
// TestBenchmarkResults_StatusCodeMap tests status code tracking
|
||||
func TestBenchmarkResults_StatusCodeMap(t *testing.T) {
|
||||
results := &BenchmarkResults{
|
||||
StatusCodes: make(map[int]int),
|
||||
}
|
||||
|
||||
// Simulate collecting status codes
|
||||
codes := []int{200, 200, 200, 201, 404, 500, 200}
|
||||
for _, code := range codes {
|
||||
results.StatusCodes[code]++
|
||||
}
|
||||
|
||||
assert.Equal(t, 4, results.StatusCodes[200])
|
||||
assert.Equal(t, 1, results.StatusCodes[201])
|
||||
assert.Equal(t, 1, results.StatusCodes[404])
|
||||
assert.Equal(t, 1, results.StatusCodes[500])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -12,6 +13,14 @@ import (
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// safeRecover recovers from panics and logs them
|
||||
// Use with defer at the start of goroutines and callbacks that could panic
|
||||
func safeRecover(context string) {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[UI] Panic recovered in %s: %v", context, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ForwardUpdateMsg is sent when a forward status changes
|
||||
type ForwardUpdateMsg struct {
|
||||
ID string
|
||||
@@ -237,13 +246,35 @@ func (ui *BubbleTeaUI) Remove(id string) {
|
||||
ui.mu.Lock()
|
||||
delete(ui.forwards, id)
|
||||
|
||||
// Clear any error associated with this forward
|
||||
delete(ui.errors, id)
|
||||
|
||||
// Remove from order
|
||||
removedIndex := -1
|
||||
for i, fid := range ui.forwardOrder {
|
||||
if fid == id {
|
||||
removedIndex = i
|
||||
ui.forwardOrder = append(ui.forwardOrder[:i], ui.forwardOrder[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust selectedIndex if necessary
|
||||
if removedIndex >= 0 {
|
||||
// If we removed the selected item or an item before it, adjust
|
||||
if ui.selectedIndex >= len(ui.forwardOrder) {
|
||||
ui.selectedIndex = len(ui.forwardOrder) - 1
|
||||
}
|
||||
// Ensure selectedIndex is never negative
|
||||
if ui.selectedIndex < 0 {
|
||||
ui.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Clear delete confirmation if we're deleting the same forward
|
||||
if ui.deleteConfirming && ui.deleteConfirmID == id {
|
||||
ui.resetDeleteConfirmation()
|
||||
}
|
||||
ui.mu.Unlock()
|
||||
|
||||
if ui.program != nil {
|
||||
@@ -321,6 +352,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case HTTPLogEntryMsg:
|
||||
return m.handleHTTPLogEntry(msg)
|
||||
|
||||
case clearCopyMessageMsg:
|
||||
m.ui.mu.Lock()
|
||||
if m.ui.httpLogState != nil {
|
||||
m.ui.httpLogState.copyMessage = ""
|
||||
}
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMessageTypes tests the message type structures
|
||||
func TestMessageTypes(t *testing.T) {
|
||||
t.Run("ContextsLoadedMsg", func(t *testing.T) {
|
||||
msg := ContextsLoadedMsg{
|
||||
contexts: []string{"ctx1", "ctx2"},
|
||||
}
|
||||
assert.Len(t, msg.contexts, 2)
|
||||
assert.Nil(t, msg.err)
|
||||
|
||||
errMsg := ContextsLoadedMsg{
|
||||
err: assert.AnError,
|
||||
}
|
||||
assert.NotNil(t, errMsg.err)
|
||||
})
|
||||
|
||||
t.Run("NamespacesLoadedMsg", func(t *testing.T) {
|
||||
msg := NamespacesLoadedMsg{
|
||||
namespaces: []string{"default", "kube-system"},
|
||||
}
|
||||
assert.Len(t, msg.namespaces, 2)
|
||||
assert.Nil(t, msg.err)
|
||||
})
|
||||
|
||||
t.Run("PodsLoadedMsg", func(t *testing.T) {
|
||||
msg := PodsLoadedMsg{
|
||||
pods: []k8s.PodInfo{
|
||||
{Name: "pod1", Namespace: "default"},
|
||||
{Name: "pod2", Namespace: "default"},
|
||||
},
|
||||
}
|
||||
assert.Len(t, msg.pods, 2)
|
||||
assert.Nil(t, msg.err)
|
||||
})
|
||||
|
||||
t.Run("ServicesLoadedMsg", func(t *testing.T) {
|
||||
msg := ServicesLoadedMsg{
|
||||
services: []k8s.ServiceInfo{
|
||||
{Name: "svc1", Namespace: "default"},
|
||||
},
|
||||
}
|
||||
assert.Len(t, msg.services, 1)
|
||||
assert.Nil(t, msg.err)
|
||||
})
|
||||
|
||||
t.Run("SelectorValidatedMsg", func(t *testing.T) {
|
||||
validMsg := SelectorValidatedMsg{
|
||||
valid: true,
|
||||
pods: []k8s.PodInfo{
|
||||
{Name: "matched-pod"},
|
||||
},
|
||||
}
|
||||
assert.True(t, validMsg.valid)
|
||||
assert.Len(t, validMsg.pods, 1)
|
||||
|
||||
invalidMsg := SelectorValidatedMsg{
|
||||
valid: false,
|
||||
err: assert.AnError,
|
||||
}
|
||||
assert.False(t, invalidMsg.valid)
|
||||
assert.NotNil(t, invalidMsg.err)
|
||||
})
|
||||
|
||||
t.Run("PortCheckedMsg", func(t *testing.T) {
|
||||
availableMsg := PortCheckedMsg{
|
||||
port: 8080,
|
||||
available: true,
|
||||
message: "Port 8080 available",
|
||||
}
|
||||
assert.Equal(t, 8080, availableMsg.port)
|
||||
assert.True(t, availableMsg.available)
|
||||
|
||||
unavailableMsg := PortCheckedMsg{
|
||||
port: 8080,
|
||||
available: false,
|
||||
message: "Port 8080 in use by process",
|
||||
}
|
||||
assert.False(t, unavailableMsg.available)
|
||||
})
|
||||
|
||||
t.Run("ForwardSavedMsg", func(t *testing.T) {
|
||||
successMsg := ForwardSavedMsg{success: true}
|
||||
assert.True(t, successMsg.success)
|
||||
|
||||
failMsg := ForwardSavedMsg{success: false, err: assert.AnError}
|
||||
assert.False(t, failMsg.success)
|
||||
assert.NotNil(t, failMsg.err)
|
||||
})
|
||||
|
||||
t.Run("ForwardsRemovedMsg", func(t *testing.T) {
|
||||
msg := ForwardsRemovedMsg{
|
||||
success: true,
|
||||
count: 3,
|
||||
}
|
||||
assert.True(t, msg.success)
|
||||
assert.Equal(t, 3, msg.count)
|
||||
})
|
||||
|
||||
t.Run("WizardCompleteMsg", func(t *testing.T) {
|
||||
msg := WizardCompleteMsg{}
|
||||
assert.NotNil(t, msg)
|
||||
})
|
||||
|
||||
t.Run("BenchmarkCompleteMsg", func(t *testing.T) {
|
||||
msg := BenchmarkCompleteMsg{
|
||||
ForwardID: "fwd-123",
|
||||
Results: nil,
|
||||
Error: nil,
|
||||
}
|
||||
assert.Equal(t, "fwd-123", msg.ForwardID)
|
||||
})
|
||||
|
||||
t.Run("BenchmarkProgressMsg", func(t *testing.T) {
|
||||
msg := BenchmarkProgressMsg{
|
||||
ForwardID: "fwd-123",
|
||||
Completed: 50,
|
||||
Total: 100,
|
||||
}
|
||||
assert.Equal(t, "fwd-123", msg.ForwardID)
|
||||
assert.Equal(t, 50, msg.Completed)
|
||||
assert.Equal(t, 100, msg.Total)
|
||||
})
|
||||
|
||||
t.Run("HTTPLogEntryMsg", func(t *testing.T) {
|
||||
msg := HTTPLogEntryMsg{
|
||||
Entry: HTTPLogEntry{
|
||||
Method: "GET",
|
||||
Path: "/api/test",
|
||||
StatusCode: 200,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "GET", msg.Entry.Method)
|
||||
assert.Equal(t, "/api/test", msg.Entry.Path)
|
||||
assert.Equal(t, 200, msg.Entry.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckPortCmd tests the port availability check command
|
||||
func TestCheckPortCmd_PortAvailability(t *testing.T) {
|
||||
// Create a temporary config file for testing
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||
|
||||
// Create an empty config file
|
||||
err := os.WriteFile(configPath, []byte("contexts: []\n"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test checking a random high port that should be available
|
||||
cmd := checkPortCmd(59999, configPath)
|
||||
msg := cmd()
|
||||
|
||||
portMsg, ok := msg.(PortCheckedMsg)
|
||||
require.True(t, ok, "Expected PortCheckedMsg")
|
||||
assert.Equal(t, 59999, portMsg.port)
|
||||
// The port may or may not be available depending on the system,
|
||||
// but we verify the message structure is correct
|
||||
assert.NotEmpty(t, portMsg.message)
|
||||
}
|
||||
|
||||
// TestCheckPortCmd_ConfigConflict tests port conflict detection in config
|
||||
func TestCheckPortCmd_ConfigConflict(t *testing.T) {
|
||||
// Create a temporary config file with a forward using port 8080
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||
|
||||
configContent := `contexts:
|
||||
- name: test-ctx
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/my-app
|
||||
port: 80
|
||||
localPort: 8080
|
||||
`
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test checking port that's already in config
|
||||
cmd := checkPortCmd(8080, configPath)
|
||||
msg := cmd()
|
||||
|
||||
portMsg, ok := msg.(PortCheckedMsg)
|
||||
require.True(t, ok, "Expected PortCheckedMsg")
|
||||
assert.Equal(t, 8080, portMsg.port)
|
||||
assert.False(t, portMsg.available, "Port should not be available (in config)")
|
||||
assert.Contains(t, portMsg.message, "already assigned")
|
||||
}
|
||||
|
||||
// TestCheckPortCmd_InvalidConfig tests behavior with invalid config file
|
||||
func TestCheckPortCmd_InvalidConfig(t *testing.T) {
|
||||
// Use a non-existent config path
|
||||
cmd := checkPortCmd(59998, "/nonexistent/path/.kportal.yaml")
|
||||
msg := cmd()
|
||||
|
||||
portMsg, ok := msg.(PortCheckedMsg)
|
||||
require.True(t, ok, "Expected PortCheckedMsg")
|
||||
// Should still return a result (just skip config check)
|
||||
assert.Equal(t, 59998, portMsg.port)
|
||||
assert.NotEmpty(t, portMsg.message)
|
||||
}
|
||||
|
||||
// TestListenBenchmarkProgressCmd tests the progress listener command
|
||||
func TestListenBenchmarkProgressCmd(t *testing.T) {
|
||||
progressCh := make(chan BenchmarkProgressMsg, 1)
|
||||
|
||||
// Send a progress message
|
||||
progressCh <- BenchmarkProgressMsg{
|
||||
ForwardID: "fwd-123",
|
||||
Completed: 25,
|
||||
Total: 100,
|
||||
}
|
||||
|
||||
cmd := listenBenchmarkProgressCmd(progressCh)
|
||||
msg := cmd()
|
||||
|
||||
progressMsg, ok := msg.(BenchmarkProgressMsg)
|
||||
require.True(t, ok, "Expected BenchmarkProgressMsg")
|
||||
assert.Equal(t, "fwd-123", progressMsg.ForwardID)
|
||||
assert.Equal(t, 25, progressMsg.Completed)
|
||||
assert.Equal(t, 100, progressMsg.Total)
|
||||
}
|
||||
|
||||
// TestListenBenchmarkProgressCmd_ChannelClosed tests behavior when channel closes
|
||||
func TestListenBenchmarkProgressCmd_ChannelClosed(t *testing.T) {
|
||||
progressCh := make(chan BenchmarkProgressMsg)
|
||||
close(progressCh)
|
||||
|
||||
cmd := listenBenchmarkProgressCmd(progressCh)
|
||||
msg := cmd()
|
||||
|
||||
assert.Nil(t, msg, "Should return nil when channel is closed")
|
||||
}
|
||||
|
||||
// TestRunBenchmarkCmd_Cancellation tests benchmark cancellation
|
||||
func TestRunBenchmarkCmd_Cancellation(t *testing.T) {
|
||||
// Create a context that's already cancelled
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
progressCh := make(chan BenchmarkProgressMsg, 100)
|
||||
|
||||
cmd := runBenchmarkCmd(ctx, "fwd-123", 59997, "/", "GET", 1, 10, progressCh)
|
||||
|
||||
// Run with timeout to prevent hanging
|
||||
done := make(chan bool, 1)
|
||||
var msg interface{}
|
||||
go func() {
|
||||
msg = cmd()
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Command completed
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runBenchmarkCmd timed out")
|
||||
}
|
||||
|
||||
completeMsg, ok := msg.(BenchmarkCompleteMsg)
|
||||
require.True(t, ok, "Expected BenchmarkCompleteMsg")
|
||||
assert.Equal(t, "fwd-123", completeMsg.ForwardID)
|
||||
// When cancelled, we expect either an error or the context cancellation message
|
||||
// The benchmark may or may not have had time to process the cancellation
|
||||
}
|
||||
|
||||
// TestK8sAPITimeout tests that the timeout constant is correct
|
||||
func TestK8sAPITimeout(t *testing.T) {
|
||||
assert.Equal(t, 10*time.Second, k8sAPITimeout)
|
||||
}
|
||||
|
||||
// TestRemovableForwardStruct tests the RemovableForward structure used by commands
|
||||
func TestRemovableForwardStruct(t *testing.T) {
|
||||
rf := RemovableForward{
|
||||
ID: "fwd-123",
|
||||
Context: "prod",
|
||||
Namespace: "default",
|
||||
Resource: "pod/my-app",
|
||||
Selector: "app=my-app",
|
||||
Alias: "my-app",
|
||||
Port: 80,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
|
||||
assert.Equal(t, "fwd-123", rf.ID)
|
||||
assert.Equal(t, "prod", rf.Context)
|
||||
assert.Equal(t, "default", rf.Namespace)
|
||||
assert.Equal(t, "pod/my-app", rf.Resource)
|
||||
assert.Equal(t, "app=my-app", rf.Selector)
|
||||
assert.Equal(t, "my-app", rf.Alias)
|
||||
assert.Equal(t, 80, rf.Port)
|
||||
assert.Equal(t, 8080, rf.LocalPort)
|
||||
}
|
||||
|
||||
// TestBenchmarkProgressCallback tests the progress callback in runBenchmarkCmd
|
||||
func TestBenchmarkProgressCallback(t *testing.T) {
|
||||
// Test that progress channel handles blocking gracefully
|
||||
progressCh := make(chan BenchmarkProgressMsg, 1) // Small buffer
|
||||
|
||||
// Fill the channel
|
||||
progressCh <- BenchmarkProgressMsg{Completed: 1, Total: 100}
|
||||
|
||||
// Test non-blocking send by creating callback similar to runBenchmarkCmd
|
||||
callback := func(completed, total int) {
|
||||
select {
|
||||
case progressCh <- BenchmarkProgressMsg{
|
||||
ForwardID: "test",
|
||||
Completed: completed,
|
||||
Total: total,
|
||||
}:
|
||||
default:
|
||||
// Drop if channel is full - should not block
|
||||
}
|
||||
}
|
||||
|
||||
// Should not block even with full channel
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
callback(50, 100) // This should not block
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Success - didn't block
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Callback blocked when channel was full")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPLogEntry tests the HTTPLogEntry structure
|
||||
func TestHTTPLogEntry(t *testing.T) {
|
||||
entry := HTTPLogEntry{
|
||||
Timestamp: "2025-11-26T10:30:00Z",
|
||||
Direction: "request",
|
||||
Method: "POST",
|
||||
Path: "/api/users",
|
||||
StatusCode: 201,
|
||||
LatencyMs: 150,
|
||||
BodySize: 1024,
|
||||
}
|
||||
|
||||
assert.Equal(t, "2025-11-26T10:30:00Z", entry.Timestamp)
|
||||
assert.Equal(t, "request", entry.Direction)
|
||||
assert.Equal(t, "POST", entry.Method)
|
||||
assert.Equal(t, "/api/users", entry.Path)
|
||||
assert.Equal(t, 201, entry.StatusCode)
|
||||
assert.Equal(t, int64(150), entry.LatencyMs)
|
||||
assert.Equal(t, 1024, entry.BodySize)
|
||||
}
|
||||
|
||||
// TestHTTPLogSubscriberType tests the HTTPLogSubscriber function type
|
||||
func TestHTTPLogSubscriberType(t *testing.T) {
|
||||
// Test that our mock matches the type
|
||||
mock := NewMockHTTPLogSubscriber()
|
||||
var subscriber HTTPLogSubscriber = mock.GetSubscriberFunc()
|
||||
|
||||
// Test subscription
|
||||
callCount := 0
|
||||
cleanup := subscriber("fwd-123", func(entry HTTPLogEntry) {
|
||||
callCount++
|
||||
})
|
||||
|
||||
// Send an entry
|
||||
mock.SendEntry("fwd-123", HTTPLogEntry{Method: "GET"})
|
||||
assert.Equal(t, 1, callCount)
|
||||
|
||||
// Clean up
|
||||
cleanup()
|
||||
assert.Equal(t, 1, mock.CleanupCalls)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestConcurrent_AddAndRemove tests concurrent add and remove operations
|
||||
// Run with: go test -race ./internal/ui/...
|
||||
func TestConcurrent_AddAndRemove(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 100
|
||||
|
||||
// Concurrent adds
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
fwd := &config.Forward{
|
||||
Resource: fmt.Sprintf("pod/app-%d", idx),
|
||||
Port: 8080 + idx,
|
||||
LocalPort: 8080 + idx,
|
||||
}
|
||||
ui.AddForward(fmt.Sprintf("id-%d", idx), fwd)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify all adds succeeded
|
||||
ui.mu.RLock()
|
||||
assert.Len(t, ui.forwards, numGoroutines)
|
||||
ui.mu.RUnlock()
|
||||
|
||||
// Concurrent removes
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ui.Remove(fmt.Sprintf("id-%d", idx))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify all removes succeeded
|
||||
ui.mu.RLock()
|
||||
assert.Len(t, ui.forwards, 0)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestConcurrent_StatusUpdates tests concurrent status updates
|
||||
func TestConcurrent_StatusUpdates(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
// Add forwards first
|
||||
for i := 0; i < 10; i++ {
|
||||
fwd := &config.Forward{
|
||||
Resource: fmt.Sprintf("pod/app-%d", i),
|
||||
Port: 8080 + i,
|
||||
LocalPort: 8080 + i,
|
||||
}
|
||||
ui.AddForward(fmt.Sprintf("id-%d", i), fwd)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numUpdates := 1000
|
||||
statuses := []string{"Active", "Starting", "Reconnecting", "Error"}
|
||||
|
||||
// Concurrent status updates
|
||||
for i := 0; i < numUpdates; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
forwardID := fmt.Sprintf("id-%d", idx%10)
|
||||
status := statuses[idx%len(statuses)]
|
||||
ui.UpdateStatus(forwardID, status)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Just verify no panics occurred - final state is non-deterministic
|
||||
ui.mu.RLock()
|
||||
assert.Len(t, ui.forwards, 10)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestConcurrent_SetErrors tests concurrent error setting
|
||||
func TestConcurrent_SetErrors(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
// Add forwards
|
||||
for i := 0; i < 10; i++ {
|
||||
fwd := &config.Forward{
|
||||
Resource: fmt.Sprintf("pod/app-%d", i),
|
||||
Port: 8080 + i,
|
||||
LocalPort: 8080 + i,
|
||||
}
|
||||
ui.AddForward(fmt.Sprintf("id-%d", i), fwd)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numErrors := 500
|
||||
|
||||
// Concurrent error setting
|
||||
for i := 0; i < numErrors; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
forwardID := fmt.Sprintf("id-%d", idx%10)
|
||||
ui.SetError(forwardID, fmt.Sprintf("error-%d", idx))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify no panics
|
||||
ui.mu.RLock()
|
||||
assert.NotEmpty(t, ui.errors)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestConcurrent_MoveSelection tests concurrent selection movement
|
||||
func TestConcurrent_MoveSelection(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
// Add forwards
|
||||
for i := 0; i < 20; i++ {
|
||||
fwd := &config.Forward{
|
||||
Resource: fmt.Sprintf("pod/app-%d", i),
|
||||
Port: 8080 + i,
|
||||
LocalPort: 8080 + i,
|
||||
}
|
||||
ui.AddForward(fmt.Sprintf("id-%d", i), fwd)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numMoves := 1000
|
||||
|
||||
// Concurrent moves
|
||||
for i := 0; i < numMoves; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
delta := 1
|
||||
if idx%2 == 0 {
|
||||
delta = -1
|
||||
}
|
||||
ui.moveSelection(delta)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify selection is within bounds
|
||||
ui.mu.RLock()
|
||||
assert.GreaterOrEqual(t, ui.selectedIndex, 0)
|
||||
assert.Less(t, ui.selectedIndex, len(ui.forwardOrder))
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestConcurrent_AddRemoveAndUpdate tests mixed concurrent operations
|
||||
func TestConcurrent_AddRemoveAndUpdate(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent adds
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
fwd := &config.Forward{
|
||||
Resource: fmt.Sprintf("pod/app-%d", idx),
|
||||
Port: 8080 + idx,
|
||||
LocalPort: 8080 + idx,
|
||||
}
|
||||
ui.AddForward(fmt.Sprintf("id-%d", idx), fwd)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent updates (some will be for non-existent forwards)
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
forwardID := fmt.Sprintf("id-%d", idx%60) // Some won't exist
|
||||
ui.UpdateStatus(forwardID, "Active")
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent removes (some will be for non-existent forwards)
|
||||
for i := 0; i < 30; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ui.Remove(fmt.Sprintf("id-%d", idx))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Just verify no panics - final state depends on execution order
|
||||
}
|
||||
|
||||
// TestConcurrent_HTTPLogEntries tests concurrent HTTP log entry additions
|
||||
func TestConcurrent_HTTPLogEntries(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Simulate the UI lock for entries
|
||||
numEntries := 1000
|
||||
|
||||
for i := 0; i < numEntries; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
entry := HTTPLogEntry{
|
||||
Method: "GET",
|
||||
Path: fmt.Sprintf("/api/test/%d", idx),
|
||||
StatusCode: 200,
|
||||
}
|
||||
mu.Lock()
|
||||
state.entries = append(state.entries, entry)
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
assert.Len(t, state.entries, numEntries)
|
||||
}
|
||||
|
||||
// TestConcurrent_FilterWhileAdding tests filtering while entries are being added
|
||||
func TestConcurrent_FilterWhileAdding(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.filterMode = HTTPLogFilterErrors
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
// Add entries concurrently
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
code := 200
|
||||
if idx%5 == 0 {
|
||||
code = 500
|
||||
}
|
||||
entry := HTTPLogEntry{
|
||||
Method: "GET",
|
||||
Path: fmt.Sprintf("/api/test/%d", idx),
|
||||
StatusCode: code,
|
||||
}
|
||||
mu.Lock()
|
||||
state.entries = append(state.entries, entry)
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Filter concurrently
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mu.Lock()
|
||||
_ = state.getFilteredEntries()
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify filtering still works
|
||||
mu.Lock()
|
||||
filtered := state.getFilteredEntries()
|
||||
mu.Unlock()
|
||||
|
||||
assert.Len(t, state.entries, 100)
|
||||
assert.Len(t, filtered, 20) // 20% are errors
|
||||
}
|
||||
|
||||
// TestConcurrent_ToggleCallback tests that toggle callback is called safely
|
||||
func TestConcurrent_ToggleCallback(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
callCount := 0
|
||||
|
||||
callback := func(id string, enable bool) {
|
||||
mu.Lock()
|
||||
callCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
ui := NewBubbleTeaUI(callback, "1.0.0")
|
||||
|
||||
// Add a forward
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Toggle many times concurrently
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ui.toggleSelected()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Give callbacks time to complete (they run in goroutines)
|
||||
// This is a basic check - in real code you'd use proper synchronization
|
||||
}
|
||||
|
||||
// TestConcurrent_WizardDependencies tests setting dependencies concurrently
|
||||
func TestConcurrent_WizardDependencies(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ui.SetWizardDependencies(nil, nil, fmt.Sprintf("/path/%d", idx))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Just verify no panics
|
||||
ui.mu.RLock()
|
||||
assert.NotEmpty(t, ui.configPath)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestConcurrent_SetUpdateAvailable tests concurrent update availability setting
|
||||
func TestConcurrent_SetUpdateAvailable(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ui.SetUpdateAvailable(fmt.Sprintf("2.0.%d", idx), "https://example.com")
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify update is available
|
||||
ui.mu.RLock()
|
||||
assert.True(t, ui.updateAvailable)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helper to create a model for testing
|
||||
func newTestModel() model {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
return model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
}
|
||||
|
||||
// Helper to create a model with a forward
|
||||
func newTestModelWithForward() model {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/my-app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
Alias: "my-app",
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
return model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
}
|
||||
|
||||
// TestHandleMainViewKeys_Quit tests quit key handling
|
||||
func TestHandleMainViewKeys_Quit(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
expected bool
|
||||
}{
|
||||
{"q", true},
|
||||
{"ctrl+c", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
m := newTestModel()
|
||||
_, cmd := m.handleMainViewKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)})
|
||||
|
||||
if tt.key == "ctrl+c" {
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
|
||||
_, cmd = m.handleMainViewKeys(keyMsg)
|
||||
}
|
||||
|
||||
// tea.Quit returns a special command
|
||||
if tt.expected {
|
||||
assert.NotNil(t, cmd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMainViewKeys_Navigation tests cursor navigation
|
||||
func TestHandleMainViewKeys_Navigation(t *testing.T) {
|
||||
m := newTestModelWithForward()
|
||||
|
||||
// Add more forwards for navigation testing
|
||||
for i := 0; i < 5; i++ {
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/app",
|
||||
Port: 8080 + i,
|
||||
LocalPort: 8080 + i,
|
||||
}
|
||||
m.ui.AddForward(string(rune('a'+i)), fwd)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
keyType tea.KeyType
|
||||
initialIndex int
|
||||
expectedIndex int
|
||||
}{
|
||||
{"down arrow", "down", tea.KeyDown, 0, 1},
|
||||
{"j key", "j", tea.KeyRunes, 0, 1},
|
||||
{"up arrow", "up", tea.KeyUp, 2, 1},
|
||||
{"k key", "k", tea.KeyRunes, 2, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m.ui.mu.Lock()
|
||||
m.ui.selectedIndex = tt.initialIndex
|
||||
m.ui.mu.Unlock()
|
||||
|
||||
var keyMsg tea.KeyMsg
|
||||
if tt.keyType == tea.KeyRunes {
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)}
|
||||
} else {
|
||||
keyMsg = tea.KeyMsg{Type: tt.keyType}
|
||||
}
|
||||
|
||||
m.handleMainViewKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, tt.expectedIndex, m.ui.selectedIndex)
|
||||
m.ui.mu.RUnlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMainViewKeys_Toggle tests space/enter toggle
|
||||
func TestHandleMainViewKeys_Toggle(t *testing.T) {
|
||||
toggleCallback := NewMockToggleCallback()
|
||||
ui := NewBubbleTeaUI(toggleCallback.GetFunc(), "1.0.0")
|
||||
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/my-app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Toggle with space
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
|
||||
m.handleMainViewKeys(keyMsg)
|
||||
|
||||
// Check disabled state changed
|
||||
m.ui.mu.RLock()
|
||||
isDisabled := m.ui.disabledMap["test-id"]
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
assert.True(t, isDisabled)
|
||||
|
||||
// Give callback goroutine time to execute
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Verify callback was called
|
||||
assert.GreaterOrEqual(t, toggleCallback.CallCount(), 1)
|
||||
}
|
||||
|
||||
// TestHandleMainViewKeys_NewWizard tests 'n' key with dependencies
|
||||
func TestHandleMainViewKeys_NewWizard(t *testing.T) {
|
||||
mockDiscovery := NewMockDiscovery()
|
||||
mockMutator := NewMockMutator()
|
||||
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.SetWizardDependencies(nil, nil, "/path/to/config") // Real Discovery/Mutator needed
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Without dependencies, 'n' should do nothing
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
|
||||
m.handleMainViewKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Nil(t, m.ui.addWizard, "Wizard should not be created without dependencies")
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// With mock (but we can't inject easily due to concrete types)
|
||||
// This test documents the expected behavior
|
||||
_ = mockDiscovery
|
||||
_ = mockMutator
|
||||
}
|
||||
|
||||
// TestHandleMainViewKeys_DeleteConfirmation tests 'd' key
|
||||
func TestHandleMainViewKeys_DeleteConfirmation(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
|
||||
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/my-app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
Alias: "my-app",
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press 'd' to show delete confirmation
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
|
||||
m.handleMainViewKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.True(t, m.ui.deleteConfirming)
|
||||
assert.Equal(t, "test-id", m.ui.deleteConfirmID)
|
||||
assert.Equal(t, "my-app", m.ui.deleteConfirmAlias)
|
||||
assert.Equal(t, 1, m.ui.deleteConfirmCursor) // Default to "No"
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate tests that 'd' doesn't overwrite
|
||||
func TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
|
||||
|
||||
fwd1 := &config.Forward{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "app1"}
|
||||
fwd2 := &config.Forward{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "app2"}
|
||||
ui.AddForward("id-1", fwd1)
|
||||
ui.AddForward("id-2", fwd2)
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press 'd' for first forward
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
|
||||
m.handleMainViewKeys(keyMsg)
|
||||
|
||||
// Change selection
|
||||
m.ui.mu.Lock()
|
||||
m.ui.selectedIndex = 1
|
||||
m.ui.mu.Unlock()
|
||||
|
||||
// Press 'd' again - should not change confirmation
|
||||
m.handleMainViewKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, "id-1", m.ui.deleteConfirmID, "Delete confirmation should not be overwritten")
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleDeleteConfirmation_Cancel tests Esc cancels delete
|
||||
func TestHandleDeleteConfirmation_Cancel(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
// Set up delete confirmation
|
||||
ui.mu.Lock()
|
||||
ui.deleteConfirming = true
|
||||
ui.deleteConfirmID = "test-id"
|
||||
ui.deleteConfirmAlias = "test-alias"
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press Esc
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
|
||||
m.handleDeleteConfirmation(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.deleteConfirming)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleDeleteConfirmation_NavigateAndConfirm tests cursor navigation in delete dialog
|
||||
func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
// Note: We use SetWizardDependencies with a real (nil) mutator since
|
||||
// the navigation test doesn't actually call mutator methods
|
||||
ui.SetWizardDependencies(nil, nil, "/path/to/config")
|
||||
ui.mu.Lock()
|
||||
ui.deleteConfirming = true
|
||||
ui.deleteConfirmID = "test-id"
|
||||
ui.deleteConfirmCursor = 1 // Start on "No"
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Navigate left to "Yes"
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
|
||||
m.handleDeleteConfirmation(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, 0, m.ui.deleteConfirmCursor)
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// Navigate right back to "No"
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
|
||||
m.handleDeleteConfirmation(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, 1, m.ui.deleteConfirmCursor)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleDeleteConfirmation_ConfirmYes tests confirming deletion
|
||||
func TestHandleDeleteConfirmation_ConfirmYes(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
// Note: The mutator needs to be set for the command to be generated,
|
||||
// but we don't call the actual mutator method in this test (just generate the cmd)
|
||||
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
|
||||
ui.mu.Lock()
|
||||
ui.deleteConfirming = true
|
||||
ui.deleteConfirmID = "test-id"
|
||||
ui.deleteConfirmCursor = 0 // On "Yes"
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press Enter on "Yes"
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
|
||||
_, cmd := m.handleDeleteConfirmation(keyMsg)
|
||||
|
||||
// Should return a command to remove the forward
|
||||
assert.NotNil(t, cmd)
|
||||
|
||||
// Dialog should be closed
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.deleteConfirming)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleDeleteConfirmation_QuickYKey tests 'y' key for quick confirm
|
||||
func TestHandleDeleteConfirmation_QuickYKey(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
// Set up with a real mutator (empty but valid) since we're testing command generation
|
||||
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
|
||||
ui.mu.Lock()
|
||||
ui.deleteConfirming = true
|
||||
ui.deleteConfirmID = "test-id"
|
||||
ui.deleteConfirmCursor = 1 // On "No"
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press 'y' - should confirm regardless of cursor position
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}
|
||||
_, cmd := m.handleDeleteConfirmation(keyMsg)
|
||||
|
||||
assert.NotNil(t, cmd)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.deleteConfirming)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleDeleteConfirmation_QuickNKey tests 'n' key for quick cancel
|
||||
func TestHandleDeleteConfirmation_QuickNKey(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.deleteConfirming = true
|
||||
ui.deleteConfirmID = "test-id"
|
||||
ui.deleteConfirmCursor = 0 // On "Yes"
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press 'n' - should cancel regardless of cursor position
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
|
||||
m.handleDeleteConfirmation(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.deleteConfirming)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleBenchmarkKeys_Cancel tests benchmark cancellation
|
||||
func TestHandleBenchmarkKeys_Cancel(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
cancelled := false
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeBenchmark
|
||||
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
|
||||
ui.benchmarkState.cancelFunc = func() { cancelled = true }
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press Esc
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
|
||||
m.handleBenchmarkKeys(keyMsg)
|
||||
|
||||
assert.True(t, cancelled, "Cancel function should be called")
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Nil(t, m.ui.benchmarkState)
|
||||
assert.Equal(t, ViewModeMain, m.ui.viewMode)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleBenchmarkKeys_Navigation tests benchmark config navigation
|
||||
func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeBenchmark
|
||||
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Initial cursor is 0
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, 0, m.ui.benchmarkState.cursor)
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// Move down
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
|
||||
m.handleBenchmarkKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// Move down again
|
||||
m.handleBenchmarkKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, 2, m.ui.benchmarkState.cursor)
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// Move up
|
||||
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
|
||||
m.handleBenchmarkKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleHTTPLogKeys_Close tests HTTP log view closing
|
||||
func TestHandleHTTPLogKeys_Close(t *testing.T) {
|
||||
mockSubscriber := NewMockHTTPLogSubscriber()
|
||||
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeHTTPLog
|
||||
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||
ui.httpLogCleanup = mockSubscriber.Subscribe("fwd-id", func(entry HTTPLogEntry) {})
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press Esc
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
|
||||
m.handleHTTPLogKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Nil(t, m.ui.httpLogState)
|
||||
assert.Nil(t, m.ui.httpLogCleanup)
|
||||
assert.Equal(t, ViewModeMain, m.ui.viewMode)
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// Verify cleanup was called
|
||||
assert.Equal(t, 1, mockSubscriber.CleanupCalls)
|
||||
}
|
||||
|
||||
// TestHandleHTTPLogKeys_FilterCycle tests filter mode cycling
|
||||
func TestHandleHTTPLogKeys_FilterCycle(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeHTTPLog
|
||||
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Initial mode is None
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// Press 'f' to cycle - should skip Text mode and go to Non200
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}
|
||||
m.handleHTTPLogKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, HTTPLogFilterNon200, m.ui.httpLogState.filterMode)
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// Press 'f' again - should go to Errors
|
||||
m.handleHTTPLogKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, HTTPLogFilterErrors, m.ui.httpLogState.filterMode)
|
||||
m.ui.mu.RUnlock()
|
||||
|
||||
// Press 'f' again - should go back to None
|
||||
m.handleHTTPLogKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleHTTPLogKeys_TextFilter tests '/' for text filter
|
||||
func TestHandleHTTPLogKeys_TextFilter(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeHTTPLog
|
||||
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press '/'
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||
m.handleHTTPLogKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.True(t, m.ui.httpLogState.filterActive)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleHTTPLogKeys_ClearFilters tests 'c' to clear filters
|
||||
func TestHandleHTTPLogKeys_ClearFilters(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeHTTPLog
|
||||
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||
ui.httpLogState.filterMode = HTTPLogFilterErrors
|
||||
ui.httpLogState.filterText = "api"
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Press 'c'
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}
|
||||
m.handleHTTPLogKeys(keyMsg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
|
||||
assert.Empty(t, m.ui.httpLogState.filterText)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleHTTPLogEntry tests HTTP log entry handling
|
||||
func TestHandleHTTPLogEntry(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeHTTPLog
|
||||
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||
ui.httpLogState.autoScroll = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Send an entry
|
||||
msg := HTTPLogEntryMsg{
|
||||
Entry: HTTPLogEntry{
|
||||
Method: "GET",
|
||||
Path: "/api/test",
|
||||
StatusCode: 200,
|
||||
},
|
||||
}
|
||||
m.handleHTTPLogEntry(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Len(t, m.ui.httpLogState.entries, 1)
|
||||
assert.Equal(t, "/api/test", m.ui.httpLogState.entries[0].Path)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleContextsLoaded tests context loading handler
|
||||
func TestHandleContextsLoaded(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.loading = true
|
||||
// Note: discovery is nil but the handler doesn't use it directly,
|
||||
// it uses the message data instead. The current context reordering
|
||||
// uses GetCurrentContext() which would fail with nil discovery,
|
||||
// but we test the basic loading behavior here.
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Simulate contexts loaded
|
||||
msg := ContextsLoadedMsg{
|
||||
contexts: []string{"default", "production", "staging"},
|
||||
}
|
||||
m.handleContextsLoaded(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.addWizard.loading)
|
||||
// Contexts should be loaded (order depends on GetCurrentContext which may fail with nil discovery)
|
||||
assert.Contains(t, m.ui.addWizard.contexts, "default")
|
||||
assert.Contains(t, m.ui.addWizard.contexts, "production")
|
||||
assert.Contains(t, m.ui.addWizard.contexts, "staging")
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleContextsLoaded_Error tests error handling
|
||||
func TestHandleContextsLoaded_Error(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.loading = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Simulate error
|
||||
expectedErr := errors.New("failed to list contexts")
|
||||
msg := ContextsLoadedMsg{
|
||||
err: expectedErr,
|
||||
}
|
||||
m.handleContextsLoaded(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.addWizard.loading)
|
||||
assert.Equal(t, expectedErr, m.ui.addWizard.error)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleNamespacesLoaded tests namespace loading handler
|
||||
func TestHandleNamespacesLoaded(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.loading = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
msg := NamespacesLoadedMsg{
|
||||
namespaces: []string{"default", "kube-system", "production"},
|
||||
}
|
||||
m.handleNamespacesLoaded(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.addWizard.loading)
|
||||
assert.Equal(t, []string{"default", "kube-system", "production"}, m.ui.addWizard.namespaces)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandlePodsLoaded tests pod loading handler
|
||||
func TestHandlePodsLoaded(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.loading = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
pods := []k8s.PodInfo{
|
||||
{Name: "app-1", Namespace: "default"},
|
||||
{Name: "app-2", Namespace: "default"},
|
||||
}
|
||||
msg := PodsLoadedMsg{pods: pods}
|
||||
m.handlePodsLoaded(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.addWizard.loading)
|
||||
assert.Len(t, m.ui.addWizard.pods, 2)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleServicesLoaded tests service loading handler
|
||||
func TestHandleServicesLoaded(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.loading = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
services := []k8s.ServiceInfo{
|
||||
{Name: "api", Namespace: "default", Ports: []k8s.PortInfo{{Port: 80}}},
|
||||
{Name: "db", Namespace: "default", Ports: []k8s.PortInfo{{Port: 5432}}},
|
||||
}
|
||||
msg := ServicesLoadedMsg{services: services}
|
||||
m.handleServicesLoaded(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.addWizard.loading)
|
||||
assert.Len(t, m.ui.addWizard.services, 2)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleSelectorValidated tests selector validation handler
|
||||
func TestHandleSelectorValidated(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.loading = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
pods := []k8s.PodInfo{
|
||||
{Name: "app-1", Namespace: "default"},
|
||||
}
|
||||
msg := SelectorValidatedMsg{
|
||||
valid: true,
|
||||
pods: pods,
|
||||
}
|
||||
m.handleSelectorValidated(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.addWizard.loading)
|
||||
assert.Len(t, m.ui.addWizard.matchingPods, 1)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandlePortChecked tests port availability check handler
|
||||
func TestHandlePortChecked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
available bool
|
||||
expectStep AddWizardStep
|
||||
expectError bool
|
||||
}{
|
||||
{"port available", true, StepConfirmation, false},
|
||||
{"port in use", false, StepEnterLocalPort, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.step = StepEnterLocalPort
|
||||
ui.addWizard.loading = true
|
||||
ui.addWizard.localPort = 8080
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
msg := PortCheckedMsg{
|
||||
port: 8080,
|
||||
available: tt.available,
|
||||
message: "test message",
|
||||
}
|
||||
m.handlePortChecked(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.addWizard.loading)
|
||||
assert.Equal(t, tt.available, m.ui.addWizard.portAvailable)
|
||||
if tt.expectError {
|
||||
assert.NotNil(t, m.ui.addWizard.error)
|
||||
} else {
|
||||
assert.Equal(t, tt.expectStep, m.ui.addWizard.step)
|
||||
}
|
||||
m.ui.mu.RUnlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleForwardSaved tests forward save handler
|
||||
func TestHandleForwardSaved(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.step = StepConfirmation
|
||||
ui.addWizard.loading = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
msg := ForwardSavedMsg{success: true}
|
||||
m.handleForwardSaved(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.addWizard.loading)
|
||||
assert.Equal(t, StepSuccess, m.ui.addWizard.step)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleForwardsRemoved tests forward removal handler
|
||||
func TestHandleForwardsRemoved(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeRemoveWizard
|
||||
ui.removeWizard = &RemoveWizardState{}
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
msg := ForwardsRemovedMsg{success: true, count: 2}
|
||||
m.handleForwardsRemoved(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Nil(t, m.ui.removeWizard)
|
||||
assert.Equal(t, ViewModeMain, m.ui.viewMode)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleBenchmarkProgress tests benchmark progress handler
|
||||
func TestHandleBenchmarkProgress(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeBenchmark
|
||||
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
|
||||
ui.benchmarkState.running = true
|
||||
ui.benchmarkState.progressCh = make(chan BenchmarkProgressMsg, 1)
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
msg := BenchmarkProgressMsg{
|
||||
ForwardID: "fwd-id",
|
||||
Completed: 50,
|
||||
Total: 100,
|
||||
}
|
||||
m.handleBenchmarkProgress(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.Equal(t, 50, m.ui.benchmarkState.progress)
|
||||
assert.Equal(t, 100, m.ui.benchmarkState.total)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestHandleBenchmarkComplete tests benchmark completion handler
|
||||
func TestHandleBenchmarkComplete(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeBenchmark
|
||||
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
|
||||
ui.benchmarkState.running = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Note: This test documents expected behavior
|
||||
// The actual BenchmarkCompleteMsg requires benchmark.Results which has CalculateStats
|
||||
msg := BenchmarkCompleteMsg{
|
||||
ForwardID: "fwd-id",
|
||||
Error: errors.New("test error"),
|
||||
}
|
||||
m.handleBenchmarkComplete(msg)
|
||||
|
||||
m.ui.mu.RLock()
|
||||
assert.False(t, m.ui.benchmarkState.running)
|
||||
assert.Equal(t, BenchmarkStepResults, m.ui.benchmarkState.step)
|
||||
assert.NotNil(t, m.ui.benchmarkState.error)
|
||||
m.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestModel_Update_MessageRouting tests message routing in Update
|
||||
func TestModel_Update_MessageRouting(t *testing.T) {
|
||||
m := newTestModelWithForward()
|
||||
|
||||
// Test window size message
|
||||
sizeMsg := tea.WindowSizeMsg{Width: 100, Height: 50}
|
||||
newModel, _ := m.Update(sizeMsg)
|
||||
updatedModel := newModel.(model)
|
||||
assert.Equal(t, 100, updatedModel.termWidth)
|
||||
assert.Equal(t, 50, updatedModel.termHeight)
|
||||
}
|
||||
|
||||
// TestModel_Update_ViewModeRouting tests that key messages are routed based on view mode
|
||||
func TestModel_Update_ViewModeRouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
viewMode ViewMode
|
||||
}{
|
||||
{"main view", ViewModeMain},
|
||||
{"add wizard", ViewModeAddWizard},
|
||||
{"benchmark", ViewModeBenchmark},
|
||||
{"http log", ViewModeHTTPLog},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = tt.viewMode
|
||||
if tt.viewMode == ViewModeAddWizard {
|
||||
ui.addWizard = newAddWizardState()
|
||||
} else if tt.viewMode == ViewModeBenchmark {
|
||||
ui.benchmarkState = newBenchmarkState("id", "alias", 8080)
|
||||
} else if tt.viewMode == ViewModeHTTPLog {
|
||||
ui.httpLogState = newHTTPLogState("id", "alias")
|
||||
}
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
// Send a key message - should not panic
|
||||
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
|
||||
_, _ = m.Update(keyMsg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWizardCompleteMsg tests wizard completion message handling
|
||||
func TestWizardCompleteMsg(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.mu.Unlock()
|
||||
|
||||
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||
|
||||
msg := WizardCompleteMsg{}
|
||||
newModel, _ := m.Update(msg)
|
||||
updatedModel := newModel.(model)
|
||||
|
||||
updatedModel.ui.mu.RLock()
|
||||
assert.Equal(t, ViewModeMain, updatedModel.ui.viewMode)
|
||||
assert.Nil(t, updatedModel.ui.addWizard)
|
||||
updatedModel.ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// Helper to check that model implements tea.Model
|
||||
func TestModel_ImplementsTeaModel(t *testing.T) {
|
||||
m := newTestModel()
|
||||
var _ tea.Model = m
|
||||
require.NotNil(t, m)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestNewHTTPLogState tests the constructor
|
||||
func TestNewHTTPLogState(t *testing.T) {
|
||||
state := newHTTPLogState("forward-123", "my-service")
|
||||
|
||||
assert.Equal(t, "forward-123", state.forwardID)
|
||||
assert.Equal(t, "my-service", state.forwardAlias)
|
||||
assert.NotNil(t, state.entries)
|
||||
assert.Empty(t, state.entries)
|
||||
assert.True(t, state.autoScroll)
|
||||
assert.Equal(t, HTTPLogFilterNone, state.filterMode)
|
||||
assert.Empty(t, state.filterText)
|
||||
assert.False(t, state.filterActive)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_NoFilter tests filtering with no filter
|
||||
func TestHTTPLogState_GetFilteredEntries_NoFilter(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||
{Method: "GET", Path: "/health", StatusCode: 200},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 3)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_FiltersZeroStatusCode tests that entries without status codes are filtered
|
||||
func TestHTTPLogState_GetFilteredEntries_FiltersZeroStatusCode(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "GET", Path: "/streaming", StatusCode: 0}, // No status (in-progress or error)
|
||||
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 2)
|
||||
assert.Equal(t, "/api/users", filtered[0].Path)
|
||||
assert.Equal(t, "/api/orders", filtered[1].Path)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_Non200Filter tests non-2xx filter
|
||||
func TestHTTPLogState_GetFilteredEntries_Non200Filter(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.filterMode = HTTPLogFilterNon200
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "GET", Path: "/api/error", StatusCode: 500},
|
||||
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||
{Method: "GET", Path: "/api/notfound", StatusCode: 404},
|
||||
{Method: "PUT", Path: "/api/redirect", StatusCode: 301},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 3)
|
||||
assert.Equal(t, 500, filtered[0].StatusCode)
|
||||
assert.Equal(t, 404, filtered[1].StatusCode)
|
||||
assert.Equal(t, 301, filtered[2].StatusCode)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_ErrorsFilter tests 4xx/5xx filter
|
||||
func TestHTTPLogState_GetFilteredEntries_ErrorsFilter(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.filterMode = HTTPLogFilterErrors
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "GET", Path: "/api/error", StatusCode: 500},
|
||||
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||
{Method: "GET", Path: "/api/notfound", StatusCode: 404},
|
||||
{Method: "PUT", Path: "/api/redirect", StatusCode: 301},
|
||||
{Method: "GET", Path: "/api/bad", StatusCode: 400},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 3)
|
||||
assert.Equal(t, 500, filtered[0].StatusCode)
|
||||
assert.Equal(t, 404, filtered[1].StatusCode)
|
||||
assert.Equal(t, 400, filtered[2].StatusCode)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_TextFilter tests text filtering
|
||||
func TestHTTPLogState_GetFilteredEntries_TextFilter(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.filterText = "users"
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "GET", Path: "/api/users/123", StatusCode: 200},
|
||||
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||
{Method: "GET", Path: "/health", StatusCode: 200},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 2)
|
||||
assert.Equal(t, "/api/users", filtered[0].Path)
|
||||
assert.Equal(t, "/api/users/123", filtered[1].Path)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_TextFilterCaseInsensitive tests case-insensitive text filtering
|
||||
func TestHTTPLogState_GetFilteredEntries_TextFilterCaseInsensitive(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.filterText = "API"
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "GET", Path: "/Api/Orders", StatusCode: 200},
|
||||
{Method: "GET", Path: "/health", StatusCode: 200},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 2)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_TextFilterByMethod tests filtering by HTTP method
|
||||
func TestHTTPLogState_GetFilteredEntries_TextFilterByMethod(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.filterText = "POST"
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||
{Method: "POST", Path: "/api/items", StatusCode: 201},
|
||||
{Method: "PUT", Path: "/api/update", StatusCode: 200},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 2)
|
||||
assert.Equal(t, "POST", filtered[0].Method)
|
||||
assert.Equal(t, "POST", filtered[1].Method)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_CombinedFilters tests combining mode and text filters
|
||||
func TestHTTPLogState_GetFilteredEntries_CombinedFilters(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.filterMode = HTTPLogFilterErrors
|
||||
state.filterText = "api"
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "GET", Path: "/api/error", StatusCode: 500},
|
||||
{Method: "GET", Path: "/health", StatusCode: 500}, // Error but doesn't match text
|
||||
{Method: "GET", Path: "/api/notfound", StatusCode: 404},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 2)
|
||||
assert.Equal(t, "/api/error", filtered[0].Path)
|
||||
assert.Equal(t, "/api/notfound", filtered[1].Path)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilteredEntries_EmptyResult tests when no entries match
|
||||
func TestHTTPLogState_GetFilteredEntries_EmptyResult(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
state.filterText = "nonexistent"
|
||||
state.entries = []HTTPLogEntry{
|
||||
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||
}
|
||||
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Empty(t, filtered)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_GetFilterModeLabel tests filter mode labels
|
||||
func TestHTTPLogState_GetFilterModeLabel(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
|
||||
tests := []struct {
|
||||
mode HTTPLogFilterMode
|
||||
expected string
|
||||
}{
|
||||
{HTTPLogFilterNone, "All"},
|
||||
{HTTPLogFilterText, "Text"},
|
||||
{HTTPLogFilterNon200, "Non-2xx"},
|
||||
{HTTPLogFilterErrors, "Errors (4xx/5xx)"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
state.filterMode = tt.mode
|
||||
assert.Equal(t, tt.expected, state.getFilterModeLabel())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPLogState_FilterModeValues tests filter mode constants are correct
|
||||
func TestHTTPLogState_FilterModeValues(t *testing.T) {
|
||||
// Ensure the modes are sequential for cycling to work correctly
|
||||
assert.Equal(t, HTTPLogFilterMode(0), HTTPLogFilterNone)
|
||||
assert.Equal(t, HTTPLogFilterMode(1), HTTPLogFilterText)
|
||||
assert.Equal(t, HTTPLogFilterMode(2), HTTPLogFilterNon200)
|
||||
assert.Equal(t, HTTPLogFilterMode(3), HTTPLogFilterErrors)
|
||||
}
|
||||
|
||||
// TestHTTPLogState_LargeEntrySet tests filtering performance with many entries
|
||||
func TestHTTPLogState_LargeEntrySet(t *testing.T) {
|
||||
state := newHTTPLogState("fwd", "alias")
|
||||
|
||||
// Add 1000 entries
|
||||
for i := 0; i < 1000; i++ {
|
||||
code := 200
|
||||
if i%10 == 0 {
|
||||
code = 500
|
||||
}
|
||||
state.entries = append(state.entries, HTTPLogEntry{
|
||||
Method: "GET",
|
||||
Path: "/api/test",
|
||||
StatusCode: code,
|
||||
})
|
||||
}
|
||||
|
||||
// Filter should work correctly
|
||||
state.filterMode = HTTPLogFilterErrors
|
||||
filtered := state.getFilteredEntries()
|
||||
|
||||
assert.Len(t, filtered, 100) // 10% are errors
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// DiscoveryInterface defines the interface for Kubernetes discovery operations
|
||||
// This allows for mocking in tests
|
||||
type DiscoveryInterface interface {
|
||||
ListContexts() ([]string, error)
|
||||
GetCurrentContext() (string, error)
|
||||
ListNamespaces(ctx context.Context, contextName string) ([]string, error)
|
||||
ListPods(ctx context.Context, contextName, namespace string) ([]k8s.PodInfo, error)
|
||||
ListPodsWithSelector(ctx context.Context, contextName, namespace, selector string) ([]k8s.PodInfo, error)
|
||||
ListServices(ctx context.Context, contextName, namespace string) ([]k8s.ServiceInfo, error)
|
||||
}
|
||||
|
||||
// MutatorInterface defines the interface for configuration mutation operations
|
||||
// This allows for mocking in tests
|
||||
type MutatorInterface interface {
|
||||
AddForward(contextName, namespaceName string, fwd config.Forward) error
|
||||
RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error
|
||||
RemoveForwardByID(id string) error
|
||||
UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure real types implement interfaces
|
||||
var _ DiscoveryInterface = (*k8s.Discovery)(nil)
|
||||
var _ MutatorInterface = (*config.Mutator)(nil)
|
||||
@@ -0,0 +1,278 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// MockDiscovery is a mock implementation of DiscoveryInterface for testing
|
||||
type MockDiscovery struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Return values
|
||||
Contexts []string
|
||||
CurrentContext string
|
||||
Namespaces []string
|
||||
Pods []k8s.PodInfo
|
||||
PodsWithSelector []k8s.PodInfo
|
||||
Services []k8s.ServiceInfo
|
||||
|
||||
// Errors to return
|
||||
ListContextsErr error
|
||||
GetCurrentContextErr error
|
||||
ListNamespacesErr error
|
||||
ListPodsErr error
|
||||
ListPodsWithSelectorErr error
|
||||
ListServicesErr error
|
||||
|
||||
// Call tracking
|
||||
ListContextsCalls int
|
||||
GetCurrentContextCalls int
|
||||
ListNamespacesCalls int
|
||||
ListPodsCalls int
|
||||
ListPodsWithSelectorCalls int
|
||||
ListServicesCalls int
|
||||
|
||||
// Captured arguments
|
||||
LastContextName string
|
||||
LastNamespace string
|
||||
LastSelector string
|
||||
}
|
||||
|
||||
func NewMockDiscovery() *MockDiscovery {
|
||||
return &MockDiscovery{
|
||||
Contexts: []string{"default", "production", "staging"},
|
||||
Namespaces: []string{"default", "kube-system"},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockDiscovery) ListContexts() ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.ListContextsCalls++
|
||||
return m.Contexts, m.ListContextsErr
|
||||
}
|
||||
|
||||
func (m *MockDiscovery) GetCurrentContext() (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.GetCurrentContextCalls++
|
||||
if m.CurrentContext == "" {
|
||||
return "default", m.GetCurrentContextErr
|
||||
}
|
||||
return m.CurrentContext, m.GetCurrentContextErr
|
||||
}
|
||||
|
||||
func (m *MockDiscovery) ListNamespaces(ctx context.Context, contextName string) ([]string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.ListNamespacesCalls++
|
||||
m.LastContextName = contextName
|
||||
return m.Namespaces, m.ListNamespacesErr
|
||||
}
|
||||
|
||||
func (m *MockDiscovery) ListPods(ctx context.Context, contextName, namespace string) ([]k8s.PodInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.ListPodsCalls++
|
||||
m.LastContextName = contextName
|
||||
m.LastNamespace = namespace
|
||||
return m.Pods, m.ListPodsErr
|
||||
}
|
||||
|
||||
func (m *MockDiscovery) ListPodsWithSelector(ctx context.Context, contextName, namespace, selector string) ([]k8s.PodInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.ListPodsWithSelectorCalls++
|
||||
m.LastContextName = contextName
|
||||
m.LastNamespace = namespace
|
||||
m.LastSelector = selector
|
||||
return m.PodsWithSelector, m.ListPodsWithSelectorErr
|
||||
}
|
||||
|
||||
func (m *MockDiscovery) ListServices(ctx context.Context, contextName, namespace string) ([]k8s.ServiceInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.ListServicesCalls++
|
||||
m.LastContextName = contextName
|
||||
m.LastNamespace = namespace
|
||||
return m.Services, m.ListServicesErr
|
||||
}
|
||||
|
||||
// MockMutator is a mock implementation of MutatorInterface for testing
|
||||
type MockMutator struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Errors to return
|
||||
AddForwardErr error
|
||||
RemoveForwardsErr error
|
||||
RemoveForwardByIDErr error
|
||||
UpdateForwardErr error
|
||||
|
||||
// Call tracking
|
||||
AddForwardCalls int
|
||||
RemoveForwardsCalls int
|
||||
RemoveForwardByIDCalls int
|
||||
UpdateForwardCalls int
|
||||
|
||||
// Captured arguments
|
||||
LastContextName string
|
||||
LastNamespaceName string
|
||||
LastForward config.Forward
|
||||
LastOldID string
|
||||
LastRemovedID string
|
||||
LastPredicate func(ctx, ns string, fwd config.Forward) bool
|
||||
|
||||
// Storage for testing
|
||||
Forwards []struct {
|
||||
Context string
|
||||
Namespace string
|
||||
Forward config.Forward
|
||||
}
|
||||
}
|
||||
|
||||
func NewMockMutator() *MockMutator {
|
||||
return &MockMutator{}
|
||||
}
|
||||
|
||||
func (m *MockMutator) AddForward(contextName, namespaceName string, fwd config.Forward) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.AddForwardCalls++
|
||||
m.LastContextName = contextName
|
||||
m.LastNamespaceName = namespaceName
|
||||
m.LastForward = fwd
|
||||
|
||||
if m.AddForwardErr == nil {
|
||||
m.Forwards = append(m.Forwards, struct {
|
||||
Context string
|
||||
Namespace string
|
||||
Forward config.Forward
|
||||
}{contextName, namespaceName, fwd})
|
||||
}
|
||||
|
||||
return m.AddForwardErr
|
||||
}
|
||||
|
||||
func (m *MockMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.RemoveForwardsCalls++
|
||||
m.LastPredicate = predicate
|
||||
return m.RemoveForwardsErr
|
||||
}
|
||||
|
||||
func (m *MockMutator) RemoveForwardByID(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.RemoveForwardByIDCalls++
|
||||
m.LastRemovedID = id
|
||||
return m.RemoveForwardByIDErr
|
||||
}
|
||||
|
||||
func (m *MockMutator) UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.UpdateForwardCalls++
|
||||
m.LastOldID = oldID
|
||||
m.LastContextName = newContextName
|
||||
m.LastNamespaceName = newNamespaceName
|
||||
m.LastForward = newFwd
|
||||
return m.UpdateForwardErr
|
||||
}
|
||||
|
||||
// MockHTTPLogSubscriber is a mock for HTTP log subscription
|
||||
type MockHTTPLogSubscriber struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// Subscription tracking
|
||||
Subscriptions map[string]func(HTTPLogEntry)
|
||||
CleanupCalls int
|
||||
|
||||
// Control
|
||||
ShouldFail bool
|
||||
}
|
||||
|
||||
func NewMockHTTPLogSubscriber() *MockHTTPLogSubscriber {
|
||||
return &MockHTTPLogSubscriber{
|
||||
Subscriptions: make(map[string]func(HTTPLogEntry)),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe returns a cleanup function
|
||||
func (m *MockHTTPLogSubscriber) Subscribe(forwardID string, callback func(HTTPLogEntry)) func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.Subscriptions[forwardID] = callback
|
||||
|
||||
return func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.CleanupCalls++
|
||||
delete(m.Subscriptions, forwardID)
|
||||
}
|
||||
}
|
||||
|
||||
// SendEntry sends an entry to a subscribed callback (for testing)
|
||||
func (m *MockHTTPLogSubscriber) SendEntry(forwardID string, entry HTTPLogEntry) {
|
||||
m.mu.Lock()
|
||||
callback, exists := m.Subscriptions[forwardID]
|
||||
m.mu.Unlock()
|
||||
|
||||
if exists && callback != nil {
|
||||
callback(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSubscriberFunc returns the function signature expected by the UI
|
||||
func (m *MockHTTPLogSubscriber) GetSubscriberFunc() HTTPLogSubscriber {
|
||||
return func(forwardID string, callback func(entry HTTPLogEntry)) func() {
|
||||
return m.Subscribe(forwardID, callback)
|
||||
}
|
||||
}
|
||||
|
||||
// MockToggleCallback tracks toggle callback invocations
|
||||
type MockToggleCallback struct {
|
||||
mu sync.Mutex
|
||||
Calls []struct {
|
||||
ID string
|
||||
Enable bool
|
||||
}
|
||||
}
|
||||
|
||||
func NewMockToggleCallback() *MockToggleCallback {
|
||||
return &MockToggleCallback{}
|
||||
}
|
||||
|
||||
func (m *MockToggleCallback) Callback(id string, enable bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Calls = append(m.Calls, struct {
|
||||
ID string
|
||||
Enable bool
|
||||
}{id, enable})
|
||||
}
|
||||
|
||||
func (m *MockToggleCallback) GetFunc() func(string, bool) {
|
||||
return m.Callback
|
||||
}
|
||||
|
||||
func (m *MockToggleCallback) CallCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.Calls)
|
||||
}
|
||||
|
||||
func (m *MockToggleCallback) LastCall() (string, bool, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if len(m.Calls) == 0 {
|
||||
return "", false, false
|
||||
}
|
||||
last := m.Calls[len(m.Calls)-1]
|
||||
return last.ID, last.Enable, true
|
||||
}
|
||||
@@ -258,6 +258,9 @@ type HTTPLogEntryMsg struct {
|
||||
Entry HTTPLogEntry
|
||||
}
|
||||
|
||||
// clearCopyMessageMsg is sent to clear the copy confirmation message
|
||||
type clearCopyMessageMsg struct{}
|
||||
|
||||
// listenBenchmarkProgressCmd listens for progress updates from the benchmark
|
||||
func listenBenchmarkProgressCmd(progressCh <-chan BenchmarkProgressMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
@@ -272,7 +275,8 @@ func listenBenchmarkProgressCmd(progressCh <-chan BenchmarkProgressMsg) tea.Cmd
|
||||
|
||||
// runBenchmarkCmd runs a benchmark against the given port forward
|
||||
// It sends progress updates via tea.Batch until completion
|
||||
func runBenchmarkCmd(forwardID string, localPort int, urlPath, method string, concurrency, requests int, progressCh chan<- BenchmarkProgressMsg) tea.Cmd {
|
||||
// The ctx parameter allows the benchmark to be cancelled from outside
|
||||
func runBenchmarkCmd(ctx context.Context, forwardID string, localPort int, urlPath, method string, concurrency, requests int, progressCh chan<- BenchmarkProgressMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
runner := benchmark.NewRunner()
|
||||
|
||||
@@ -284,6 +288,12 @@ func runBenchmarkCmd(forwardID string, localPort int, urlPath, method string, co
|
||||
Requests: requests,
|
||||
Timeout: 30 * time.Second,
|
||||
ProgressCallback: func(completed, total int) {
|
||||
// Recover from panics in the callback
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Silently recover - progress callback failure shouldn't crash the benchmark
|
||||
}
|
||||
}()
|
||||
// Non-blocking send to progress channel
|
||||
select {
|
||||
case progressCh <- BenchmarkProgressMsg{
|
||||
@@ -297,14 +307,24 @@ func runBenchmarkCmd(forwardID string, localPort int, urlPath, method string, co
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
// Use the provided context with a timeout as a safety limit
|
||||
benchCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
results, err := runner.Run(ctx, forwardID, cfg)
|
||||
results, err := runner.Run(benchCtx, forwardID, cfg)
|
||||
|
||||
// Close the progress channel when done
|
||||
close(progressCh)
|
||||
|
||||
// Check if cancelled
|
||||
if ctx.Err() != nil {
|
||||
return BenchmarkCompleteMsg{
|
||||
ForwardID: forwardID,
|
||||
Results: nil,
|
||||
Error: fmt.Errorf("benchmark cancelled"),
|
||||
}
|
||||
}
|
||||
|
||||
return BenchmarkCompleteMsg{
|
||||
ForwardID: forwardID,
|
||||
Results: results,
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestWizardMutualExclusion_AddWizardBlocksOthers tests that having an add wizard active blocks other modals
|
||||
func TestWizardMutualExclusion_AddWizardBlocksOthers(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
// Add a forward so we have something to select
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/my-app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
// Activate add wizard
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.mu.Unlock()
|
||||
|
||||
// Verify state
|
||||
ui.mu.RLock()
|
||||
assert.NotNil(t, ui.addWizard)
|
||||
assert.Equal(t, ViewModeAddWizard, ui.viewMode)
|
||||
ui.mu.RUnlock()
|
||||
|
||||
// Check that other modals cannot be activated when add wizard is active
|
||||
// This is enforced in the handlers, not in state - we're testing the state setup
|
||||
}
|
||||
|
||||
// TestWizardMutualExclusion_BenchmarkBlocksOthers tests that having benchmark active blocks other modals
|
||||
func TestWizardMutualExclusion_BenchmarkBlocksOthers(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
// Add a forward
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/my-app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
// Activate benchmark
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeBenchmark
|
||||
ui.benchmarkState = newBenchmarkState("test-id", "my-app", 8080)
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.mu.RLock()
|
||||
assert.NotNil(t, ui.benchmarkState)
|
||||
assert.Equal(t, ViewModeBenchmark, ui.viewMode)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestWizardMutualExclusion_HTTPLogBlocksOthers tests that having HTTP log view active blocks other modals
|
||||
func TestWizardMutualExclusion_HTTPLogBlocksOthers(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
// Add a forward
|
||||
fwd := &config.Forward{
|
||||
Resource: "pod/my-app",
|
||||
Port: 8080,
|
||||
LocalPort: 8080,
|
||||
}
|
||||
ui.AddForward("test-id", fwd)
|
||||
|
||||
// Activate HTTP log view
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeHTTPLog
|
||||
ui.httpLogState = newHTTPLogState("test-id", "my-app")
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.mu.RLock()
|
||||
assert.NotNil(t, ui.httpLogState)
|
||||
assert.Equal(t, ViewModeHTTPLog, ui.viewMode)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestWizardMutualExclusion_CheckActiveModal tests the modal activity check logic
|
||||
func TestWizardMutualExclusion_CheckActiveModal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(*BubbleTeaUI)
|
||||
expectActive bool
|
||||
activeModalStr string
|
||||
}{
|
||||
{
|
||||
name: "no modal active",
|
||||
setupFunc: func(ui *BubbleTeaUI) {},
|
||||
expectActive: false,
|
||||
activeModalStr: "none",
|
||||
},
|
||||
{
|
||||
name: "add wizard active",
|
||||
setupFunc: func(ui *BubbleTeaUI) {
|
||||
ui.addWizard = newAddWizardState()
|
||||
},
|
||||
expectActive: true,
|
||||
activeModalStr: "addWizard",
|
||||
},
|
||||
{
|
||||
name: "remove wizard active",
|
||||
setupFunc: func(ui *BubbleTeaUI) {
|
||||
ui.removeWizard = &RemoveWizardState{}
|
||||
},
|
||||
expectActive: true,
|
||||
activeModalStr: "removeWizard",
|
||||
},
|
||||
{
|
||||
name: "benchmark active",
|
||||
setupFunc: func(ui *BubbleTeaUI) {
|
||||
ui.benchmarkState = newBenchmarkState("id", "alias", 8080)
|
||||
},
|
||||
expectActive: true,
|
||||
activeModalStr: "benchmark",
|
||||
},
|
||||
{
|
||||
name: "http log active",
|
||||
setupFunc: func(ui *BubbleTeaUI) {
|
||||
ui.httpLogState = newHTTPLogState("id", "alias")
|
||||
},
|
||||
expectActive: true,
|
||||
activeModalStr: "httpLog",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
ui.mu.Lock()
|
||||
tt.setupFunc(ui)
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.mu.RLock()
|
||||
hasActiveModal := ui.addWizard != nil ||
|
||||
ui.removeWizard != nil ||
|
||||
ui.benchmarkState != nil ||
|
||||
ui.httpLogState != nil
|
||||
ui.mu.RUnlock()
|
||||
|
||||
assert.Equal(t, tt.expectActive, hasActiveModal, "Modal activity check failed for: %s", tt.activeModalStr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWizardCleanup_AddWizardReset tests that add wizard state is properly cleaned up
|
||||
func TestWizardCleanup_AddWizardReset(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
// Set up wizard with various state
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeAddWizard
|
||||
ui.addWizard = newAddWizardState()
|
||||
ui.addWizard.step = StepSelectNamespace
|
||||
ui.addWizard.selectedContext = "prod"
|
||||
ui.addWizard.contexts = []string{"prod", "staging"}
|
||||
ui.mu.Unlock()
|
||||
|
||||
// Simulate cleanup (like pressing Esc)
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeMain
|
||||
ui.addWizard = nil
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.mu.RLock()
|
||||
assert.Nil(t, ui.addWizard)
|
||||
assert.Equal(t, ViewModeMain, ui.viewMode)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestWizardCleanup_BenchmarkReset tests that benchmark state is properly cleaned up
|
||||
func TestWizardCleanup_BenchmarkReset(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
cancelled := false
|
||||
|
||||
// Set up benchmark with cancel function
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeBenchmark
|
||||
ui.benchmarkState = newBenchmarkState("id", "alias", 8080)
|
||||
ui.benchmarkState.running = true
|
||||
ui.benchmarkState.cancelFunc = func() { cancelled = true }
|
||||
ui.mu.Unlock()
|
||||
|
||||
// Simulate cleanup with cancel
|
||||
ui.mu.Lock()
|
||||
if ui.benchmarkState.cancelFunc != nil {
|
||||
ui.benchmarkState.cancelFunc()
|
||||
}
|
||||
ui.viewMode = ViewModeMain
|
||||
ui.benchmarkState = nil
|
||||
ui.mu.Unlock()
|
||||
|
||||
assert.True(t, cancelled, "Cancel function should have been called")
|
||||
|
||||
ui.mu.RLock()
|
||||
assert.Nil(t, ui.benchmarkState)
|
||||
assert.Equal(t, ViewModeMain, ui.viewMode)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestWizardCleanup_HTTPLogReset tests that HTTP log state is properly cleaned up
|
||||
func TestWizardCleanup_HTTPLogReset(t *testing.T) {
|
||||
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||
|
||||
cleanupCalled := false
|
||||
|
||||
// Set up HTTP log with cleanup function
|
||||
ui.mu.Lock()
|
||||
ui.viewMode = ViewModeHTTPLog
|
||||
ui.httpLogState = newHTTPLogState("id", "alias")
|
||||
ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/"}}
|
||||
ui.httpLogCleanup = func() { cleanupCalled = true }
|
||||
ui.mu.Unlock()
|
||||
|
||||
// Simulate cleanup
|
||||
ui.mu.Lock()
|
||||
if ui.httpLogCleanup != nil {
|
||||
ui.httpLogCleanup()
|
||||
ui.httpLogCleanup = nil
|
||||
}
|
||||
ui.viewMode = ViewModeMain
|
||||
ui.httpLogState = nil
|
||||
ui.mu.Unlock()
|
||||
|
||||
assert.True(t, cleanupCalled, "Cleanup function should have been called")
|
||||
|
||||
ui.mu.RLock()
|
||||
assert.Nil(t, ui.httpLogState)
|
||||
assert.Nil(t, ui.httpLogCleanup)
|
||||
assert.Equal(t, ViewModeMain, ui.viewMode)
|
||||
ui.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TestViewModeValues tests view mode constants
|
||||
func TestViewModeValues(t *testing.T) {
|
||||
assert.Equal(t, ViewMode(0), ViewModeMain)
|
||||
assert.Equal(t, ViewMode(1), ViewModeAddWizard)
|
||||
assert.Equal(t, ViewMode(2), ViewModeRemoveWizard)
|
||||
assert.Equal(t, ViewMode(3), ViewModeBenchmark)
|
||||
assert.Equal(t, ViewMode(4), ViewModeHTTPLog)
|
||||
}
|
||||
|
||||
// TestRemoveWizardState_Selection tests remove wizard selection logic
|
||||
func TestRemoveWizardState_Selection(t *testing.T) {
|
||||
wizard := &RemoveWizardState{
|
||||
forwards: []RemovableForward{
|
||||
{ID: "a", Alias: "app-a"},
|
||||
{ID: "b", Alias: "app-b"},
|
||||
{ID: "c", Alias: "app-c"},
|
||||
},
|
||||
selected: make(map[int]bool),
|
||||
cursor: 0,
|
||||
}
|
||||
|
||||
// Toggle selection
|
||||
wizard.toggleSelection()
|
||||
assert.True(t, wizard.selected[0])
|
||||
|
||||
// Move and toggle
|
||||
wizard.moveCursor(1)
|
||||
wizard.toggleSelection()
|
||||
assert.True(t, wizard.selected[1])
|
||||
|
||||
// Check selected count
|
||||
assert.Equal(t, 2, wizard.getSelectedCount())
|
||||
|
||||
// Get selected forwards
|
||||
selected := wizard.getSelectedForwards()
|
||||
assert.Len(t, selected, 2)
|
||||
}
|
||||
|
||||
// TestRemoveWizardState_SelectAll tests select all functionality
|
||||
func TestRemoveWizardState_SelectAll(t *testing.T) {
|
||||
wizard := &RemoveWizardState{
|
||||
forwards: []RemovableForward{
|
||||
{ID: "a"},
|
||||
{ID: "b"},
|
||||
{ID: "c"},
|
||||
},
|
||||
selected: make(map[int]bool),
|
||||
}
|
||||
|
||||
wizard.selectAll()
|
||||
|
||||
assert.Equal(t, 3, wizard.getSelectedCount())
|
||||
assert.True(t, wizard.selected[0])
|
||||
assert.True(t, wizard.selected[1])
|
||||
assert.True(t, wizard.selected[2])
|
||||
}
|
||||
|
||||
// TestRemoveWizardState_SelectNone tests deselect all functionality
|
||||
func TestRemoveWizardState_SelectNone(t *testing.T) {
|
||||
wizard := &RemoveWizardState{
|
||||
forwards: []RemovableForward{
|
||||
{ID: "a"},
|
||||
{ID: "b"},
|
||||
{ID: "c"},
|
||||
},
|
||||
selected: map[int]bool{0: true, 1: true, 2: true},
|
||||
}
|
||||
|
||||
wizard.selectNone()
|
||||
|
||||
assert.Equal(t, 0, wizard.getSelectedCount())
|
||||
}
|
||||
|
||||
// TestRemoveWizardState_MoveCursor tests cursor movement in remove wizard
|
||||
func TestRemoveWizardState_MoveCursor(t *testing.T) {
|
||||
wizard := &RemoveWizardState{
|
||||
forwards: []RemovableForward{
|
||||
{ID: "a"},
|
||||
{ID: "b"},
|
||||
{ID: "c"},
|
||||
},
|
||||
selected: make(map[int]bool),
|
||||
cursor: 0,
|
||||
}
|
||||
|
||||
// Move down
|
||||
wizard.moveCursor(1)
|
||||
assert.Equal(t, 1, wizard.cursor)
|
||||
|
||||
// Move down again
|
||||
wizard.moveCursor(1)
|
||||
assert.Equal(t, 2, wizard.cursor)
|
||||
|
||||
// Cannot go past end
|
||||
wizard.moveCursor(1)
|
||||
assert.Equal(t, 2, wizard.cursor)
|
||||
|
||||
// Move up
|
||||
wizard.moveCursor(-1)
|
||||
assert.Equal(t, 1, wizard.cursor)
|
||||
|
||||
// Cannot go below 0
|
||||
wizard.moveCursor(-10)
|
||||
assert.Equal(t, 0, wizard.cursor)
|
||||
}
|
||||
|
||||
// TestRemoveWizardState_ConfirmationMode tests confirmation mode cursor
|
||||
func TestRemoveWizardState_ConfirmationMode(t *testing.T) {
|
||||
wizard := &RemoveWizardState{
|
||||
forwards: []RemovableForward{{ID: "a"}},
|
||||
selected: map[int]bool{0: true},
|
||||
confirming: true,
|
||||
confirmCursor: 0,
|
||||
}
|
||||
|
||||
// In confirmation mode, cursor moves between Yes/No
|
||||
wizard.moveCursor(1)
|
||||
assert.Equal(t, 1, wizard.confirmCursor)
|
||||
|
||||
// Cannot go past 1
|
||||
wizard.moveCursor(1)
|
||||
assert.Equal(t, 1, wizard.confirmCursor)
|
||||
|
||||
// Move back
|
||||
wizard.moveCursor(-1)
|
||||
assert.Equal(t, 0, wizard.confirmCursor)
|
||||
|
||||
// Cannot go below 0
|
||||
wizard.moveCursor(-1)
|
||||
assert.Equal(t, 0, wizard.confirmCursor)
|
||||
}
|
||||
|
||||
// TestRemoveWizardState_ToggleInConfirmationMode tests that toggle is disabled in confirmation mode
|
||||
func TestRemoveWizardState_ToggleInConfirmationMode(t *testing.T) {
|
||||
wizard := &RemoveWizardState{
|
||||
forwards: []RemovableForward{{ID: "a"}},
|
||||
selected: make(map[int]bool),
|
||||
confirming: true,
|
||||
}
|
||||
|
||||
// Toggle should be no-op in confirmation mode
|
||||
wizard.toggleSelection()
|
||||
assert.Equal(t, 0, wizard.getSelectedCount())
|
||||
}
|
||||
+173
-23
@@ -1,13 +1,16 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
// isFilterableStep returns true if the step supports search/filter
|
||||
@@ -51,6 +54,11 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "n": // Enter add wizard
|
||||
m.ui.mu.Lock()
|
||||
// Don't create a new wizard if one is already active
|
||||
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
if m.ui.discovery == nil || m.ui.mutator == nil {
|
||||
// Dependencies not set up
|
||||
m.ui.mu.Unlock()
|
||||
@@ -67,6 +75,11 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "e": // Edit selected forward
|
||||
m.ui.mu.Lock()
|
||||
// Don't create a new wizard if one is already active
|
||||
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if len(m.ui.forwardOrder) == 0 {
|
||||
// No forwards to edit
|
||||
@@ -133,6 +146,12 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "d": // Delete currently selected forward - show confirmation
|
||||
m.ui.mu.Lock()
|
||||
|
||||
// Don't overwrite existing confirmation dialog
|
||||
if m.ui.deleteConfirming {
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if len(m.ui.forwardOrder) == 0 {
|
||||
// No forwards to delete
|
||||
m.ui.mu.Unlock()
|
||||
@@ -163,13 +182,18 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.ui.deleteConfirming = true
|
||||
m.ui.deleteConfirmID = selectedID
|
||||
m.ui.deleteConfirmAlias = selectedForward.Alias
|
||||
m.ui.deleteConfirmCursor = 0 // Default to "No" for safety
|
||||
m.ui.deleteConfirmCursor = 1 // Default to "No" for safety
|
||||
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
|
||||
case "b": // Benchmark selected forward
|
||||
m.ui.mu.Lock()
|
||||
// Don't create benchmark view if another modal is active
|
||||
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if len(m.ui.forwardOrder) == 0 {
|
||||
m.ui.mu.Unlock()
|
||||
@@ -200,6 +224,11 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "l": // View HTTP logs for selected forward
|
||||
m.ui.mu.Lock()
|
||||
// Don't create log view if another modal is active
|
||||
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if len(m.ui.forwardOrder) == 0 {
|
||||
m.ui.mu.Unlock()
|
||||
@@ -223,18 +252,33 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.ui.viewMode = ViewModeHTTPLog
|
||||
m.ui.httpLogState = newHTTPLogState(selectedID, selectedForward.Alias)
|
||||
|
||||
// Capture subscriber and UI reference for the callback
|
||||
subscriber := m.ui.httpLogSubscriber
|
||||
ui := m.ui
|
||||
m.ui.mu.Unlock()
|
||||
|
||||
// Subscribe to HTTP logs if subscriber is available
|
||||
if m.ui.httpLogSubscriber != nil {
|
||||
cleanup := m.ui.httpLogSubscriber(selectedID, func(entry HTTPLogEntry) {
|
||||
// Add entry to state (thread-safe via Send)
|
||||
if m.ui.program != nil {
|
||||
m.ui.program.Send(HTTPLogEntryMsg{Entry: entry})
|
||||
// This is done outside the lock to prevent deadlocks in the callback
|
||||
if subscriber != nil {
|
||||
cleanup := subscriber(selectedID, func(entry HTTPLogEntry) {
|
||||
// Recover from panics in the callback
|
||||
defer safeRecover("HTTPLogSubscriber callback")
|
||||
|
||||
// Use RLock to safely access program
|
||||
ui.mu.RLock()
|
||||
program := ui.program
|
||||
ui.mu.RUnlock()
|
||||
|
||||
// Send entry to program (thread-safe via Send)
|
||||
if program != nil {
|
||||
program.Send(HTTPLogEntryMsg{Entry: entry})
|
||||
}
|
||||
})
|
||||
m.ui.httpLogCleanup = cleanup
|
||||
ui.mu.Lock()
|
||||
ui.httpLogCleanup = cleanup
|
||||
ui.mu.Unlock()
|
||||
}
|
||||
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -744,17 +788,21 @@ func (m model) handleContextsLoaded(msg ContextsLoadedMsg) (tea.Model, tea.Cmd)
|
||||
m.ui.addWizard.loading = false
|
||||
m.ui.addWizard.error = msg.err
|
||||
if msg.err == nil {
|
||||
// Get current context and move it to the top
|
||||
currentCtx, err := m.ui.discovery.GetCurrentContext()
|
||||
if err == nil && currentCtx != "" {
|
||||
// Reorder contexts with current first
|
||||
reordered := []string{currentCtx}
|
||||
for _, ctx := range msg.contexts {
|
||||
if ctx != currentCtx {
|
||||
reordered = append(reordered, ctx)
|
||||
// Get current context and move it to the top (if discovery is available)
|
||||
if m.ui.discovery != nil {
|
||||
currentCtx, err := m.ui.discovery.GetCurrentContext()
|
||||
if err == nil && currentCtx != "" {
|
||||
// Reorder contexts with current first
|
||||
reordered := []string{currentCtx}
|
||||
for _, ctx := range msg.contexts {
|
||||
if ctx != currentCtx {
|
||||
reordered = append(reordered, ctx)
|
||||
}
|
||||
}
|
||||
m.ui.addWizard.contexts = reordered
|
||||
} else {
|
||||
m.ui.addWizard.contexts = msg.contexts
|
||||
}
|
||||
m.ui.addWizard.contexts = reordered
|
||||
} else {
|
||||
m.ui.addWizard.contexts = msg.contexts
|
||||
}
|
||||
@@ -926,7 +974,11 @@ func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
// Cancel and return to main view
|
||||
// Cancel the running benchmark if active
|
||||
if state.cancelFunc != nil {
|
||||
state.cancelFunc()
|
||||
}
|
||||
// Return to main view
|
||||
m.ui.viewMode = ViewModeMain
|
||||
m.ui.benchmarkState = nil
|
||||
return m, tea.ClearScreen
|
||||
@@ -962,9 +1014,12 @@ func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
state.total = state.requests
|
||||
// Create progress channel with buffer for non-blocking sends
|
||||
state.progressCh = make(chan BenchmarkProgressMsg, 10)
|
||||
// Create cancellable context for the benchmark
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
state.cancelFunc = cancel
|
||||
// Return batch command to run benchmark and listen for progress
|
||||
return m, tea.Batch(
|
||||
runBenchmarkCmd(state.forwardID, state.localPort, state.urlPath, state.method, state.concurrency, state.requests, state.progressCh),
|
||||
runBenchmarkCmd(ctx, state.forwardID, state.localPort, state.urlPath, state.method, state.concurrency, state.requests, state.progressCh),
|
||||
listenBenchmarkProgressCmd(state.progressCh),
|
||||
)
|
||||
case BenchmarkStepResults:
|
||||
@@ -1095,6 +1150,62 @@ func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
filteredEntries := state.getFilteredEntries()
|
||||
|
||||
// If viewing detail, handle detail view keys
|
||||
if state.showingDetail {
|
||||
switch msg.String() {
|
||||
case "esc", "q", "enter":
|
||||
// Return to list view
|
||||
state.showingDetail = false
|
||||
state.detailScroll = 0
|
||||
state.copyMessage = ""
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
if state.detailScroll > 0 {
|
||||
state.detailScroll--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
state.detailScroll++
|
||||
return m, nil
|
||||
case "pgup", "ctrl+u":
|
||||
state.detailScroll -= 20
|
||||
if state.detailScroll < 0 {
|
||||
state.detailScroll = 0
|
||||
}
|
||||
return m, nil
|
||||
case "pgdown", "ctrl+d":
|
||||
state.detailScroll += 20
|
||||
return m, nil
|
||||
case "g":
|
||||
state.detailScroll = 0
|
||||
return m, nil
|
||||
case "c":
|
||||
// Copy response body to clipboard
|
||||
if state.cursor >= 0 && state.cursor < len(filteredEntries) {
|
||||
entry := filteredEntries[state.cursor]
|
||||
if entry.ResponseBody != "" {
|
||||
// Decompress if needed before copying
|
||||
body := decompressContent(entry.ResponseBody, entry.ResponseHeaders)
|
||||
if err := clipboard.Init(); err == nil {
|
||||
clipboard.Write(clipboard.FmtText, []byte(body))
|
||||
state.copyMessage = "Copied!"
|
||||
// Clear the message after 2 seconds
|
||||
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
||||
return clearCopyMessageMsg{}
|
||||
})
|
||||
} else {
|
||||
state.copyMessage = "Clipboard unavailable"
|
||||
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
||||
return clearCopyMessageMsg{}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "q":
|
||||
// Cleanup subscription before closing
|
||||
@@ -1107,6 +1218,14 @@ func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.ui.httpLogState = nil
|
||||
return m, tea.ClearScreen
|
||||
|
||||
case "enter":
|
||||
// Show detail view for selected entry
|
||||
if len(filteredEntries) > 0 && state.cursor >= 0 && state.cursor < len(filteredEntries) {
|
||||
state.showingDetail = true
|
||||
state.detailScroll = 0
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "up", "k":
|
||||
if state.cursor > 0 {
|
||||
state.cursor--
|
||||
@@ -1162,8 +1281,14 @@ func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
state.autoScroll = !state.autoScroll
|
||||
|
||||
case "f":
|
||||
// Cycle filter mode
|
||||
state.cycleFilterMode()
|
||||
// Cycle filter mode (skip Text mode when cycling - use '/' for text filter)
|
||||
state.filterMode = (state.filterMode + 1) % 4
|
||||
if state.filterMode == HTTPLogFilterText {
|
||||
// Skip Text mode when using 'f' - it's only accessible via '/'
|
||||
state.filterMode = HTTPLogFilterNon200
|
||||
}
|
||||
state.cursor = 0
|
||||
state.scrollOffset = 0
|
||||
|
||||
case "/":
|
||||
// Enter text filter mode
|
||||
@@ -1191,7 +1316,28 @@ func (m model) handleHTTPLogEntry(msg HTTPLogEntryMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
state := m.ui.httpLogState
|
||||
state.entries = append(state.entries, msg.Entry)
|
||||
entry := msg.Entry
|
||||
|
||||
// If this is a response, try to find and merge with the matching request
|
||||
if entry.Direction == "response" && entry.RequestID != "" {
|
||||
// Search backwards (responses follow requests closely)
|
||||
for i := len(state.entries) - 1; i >= 0 && i >= len(state.entries)-100; i-- {
|
||||
if state.entries[i].RequestID == entry.RequestID && state.entries[i].Direction == "request" {
|
||||
// Merge response data into the existing request entry
|
||||
state.entries[i].Direction = "response"
|
||||
state.entries[i].StatusCode = entry.StatusCode
|
||||
state.entries[i].LatencyMs = entry.LatencyMs
|
||||
state.entries[i].BodySize = entry.BodySize
|
||||
state.entries[i].ResponseHeaders = entry.ResponseHeaders
|
||||
state.entries[i].ResponseBody = entry.ResponseBody
|
||||
state.entries[i].Error = entry.Error
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For requests or unmatched responses, append as new entry
|
||||
state.entries = append(state.entries, entry)
|
||||
|
||||
// Cap entries to prevent memory growth (keep last 10000 entries)
|
||||
const maxEntries = 10000
|
||||
@@ -1206,7 +1352,11 @@ func (m model) handleHTTPLogEntry(msg HTTPLogEntryMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Auto-scroll to bottom if enabled
|
||||
if state.autoScroll && len(state.entries) > 0 {
|
||||
state.cursor = len(state.entries) - 1
|
||||
filteredEntries := state.getFilteredEntries()
|
||||
state.cursor = len(filteredEntries) - 1
|
||||
if state.cursor < 0 {
|
||||
state.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
|
||||
@@ -405,6 +405,7 @@ type BenchmarkState struct {
|
||||
progress int
|
||||
total int
|
||||
progressCh chan BenchmarkProgressMsg // Channel for progress updates
|
||||
cancelFunc func() // Function to cancel the running benchmark
|
||||
|
||||
// Results
|
||||
results *BenchmarkResults
|
||||
@@ -465,10 +466,16 @@ type HTTPLogState struct {
|
||||
filterMode HTTPLogFilterMode
|
||||
filterText string
|
||||
filterActive bool // true when typing in filter input
|
||||
|
||||
// Detail view
|
||||
showingDetail bool // true when viewing full entry details
|
||||
detailScroll int // scroll position in detail view
|
||||
copyMessage string // temporary message after copying (e.g., "Copied!")
|
||||
}
|
||||
|
||||
// HTTPLogEntry represents a single HTTP log entry for display
|
||||
type HTTPLogEntry struct {
|
||||
RequestID string // Used to match request/response pairs
|
||||
Timestamp string
|
||||
Direction string
|
||||
Method string
|
||||
@@ -476,6 +483,13 @@ type HTTPLogEntry struct {
|
||||
StatusCode int
|
||||
LatencyMs int64
|
||||
BodySize int
|
||||
|
||||
// Detail fields - for viewing full request/response
|
||||
RequestHeaders map[string]string
|
||||
ResponseHeaders map[string]string
|
||||
RequestBody string
|
||||
ResponseBody string
|
||||
Error string
|
||||
}
|
||||
|
||||
// newHTTPLogState creates a new HTTP log viewing state
|
||||
@@ -529,13 +543,6 @@ func (s *HTTPLogState) getFilteredEntries() []HTTPLogEntry {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// cycleFilterMode cycles through filter modes
|
||||
func (s *HTTPLogState) cycleFilterMode() {
|
||||
s.filterMode = (s.filterMode + 1) % 4
|
||||
s.cursor = 0
|
||||
s.scrollOffset = 0
|
||||
}
|
||||
|
||||
// getFilterModeLabel returns a label for the current filter mode
|
||||
func (s *HTTPLogState) getFilterModeLabel() string {
|
||||
switch s.filterMode {
|
||||
|
||||
@@ -16,6 +16,13 @@ var (
|
||||
mutedColor = lipgloss.Color("241") // Gray
|
||||
accentColor = lipgloss.Color("63") // Purple
|
||||
highlightColor = lipgloss.Color("117") // Light blue
|
||||
|
||||
// JSON syntax highlighting colors
|
||||
jsonKeyColor = lipgloss.Color("81") // Cyan
|
||||
jsonStringColor = lipgloss.Color("180") // Light orange/tan
|
||||
jsonNumberColor = lipgloss.Color("141") // Light purple
|
||||
jsonBoolColor = lipgloss.Color("209") // Orange
|
||||
jsonNullColor = lipgloss.Color("243") // Dark gray
|
||||
)
|
||||
|
||||
// Text styles
|
||||
@@ -84,6 +91,24 @@ var (
|
||||
Foreground(mutedColor)
|
||||
)
|
||||
|
||||
// JSON syntax highlighting styles
|
||||
var (
|
||||
jsonKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(jsonKeyColor)
|
||||
|
||||
jsonStringStyle = lipgloss.NewStyle().
|
||||
Foreground(jsonStringColor)
|
||||
|
||||
jsonNumberStyle = lipgloss.NewStyle().
|
||||
Foreground(jsonNumberColor)
|
||||
|
||||
jsonBoolStyle = lipgloss.NewStyle().
|
||||
Foreground(jsonBoolColor)
|
||||
|
||||
jsonNullStyle = lipgloss.NewStyle().
|
||||
Foreground(jsonNullColor)
|
||||
)
|
||||
|
||||
// Container styles
|
||||
var (
|
||||
// wizardBoxStyle creates a bordered modal box
|
||||
|
||||
+486
-1
@@ -1,7 +1,13 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -894,6 +900,11 @@ func (m model) renderHTTPLog() string {
|
||||
totalEntries := len(filteredEntries)
|
||||
totalUnfiltered := len(state.entries)
|
||||
|
||||
// If showing detail view, render that instead
|
||||
if state.showingDetail && state.cursor >= 0 && state.cursor < len(filteredEntries) {
|
||||
return m.renderHTTPLogDetail(filteredEntries[state.cursor], termWidth, termHeight)
|
||||
}
|
||||
|
||||
// Build output
|
||||
var b strings.Builder
|
||||
|
||||
@@ -1066,7 +1077,481 @@ func (m model) renderHTTPLog() string {
|
||||
b.WriteString("\n")
|
||||
|
||||
// Help line at bottom
|
||||
b.WriteString(helpStyle.Render(" ↑/↓/PgUp/PgDn: Navigate g/G: Top/Bottom a: Auto-scroll f: Filter /: Search c: Clear q: Close"))
|
||||
b.WriteString(helpStyle.Render(" ↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderHTTPLogDetail renders the detailed view of a single HTTP log entry
|
||||
func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Header
|
||||
title := wizardHeaderStyle.Render("HTTP Request Detail")
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Build content lines for scrolling
|
||||
var lines []string
|
||||
|
||||
// Request summary
|
||||
lines = append(lines, accentStyle.Render("─── Request ───────────────────────────────────────────"))
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fmt.Sprintf(" %s %s", successStyle.Render(entry.Method), entry.Path))
|
||||
lines = append(lines, fmt.Sprintf(" Time: %s", entry.Timestamp))
|
||||
lines = append(lines, "")
|
||||
|
||||
// Request headers (sorted alphabetically)
|
||||
if len(entry.RequestHeaders) > 0 {
|
||||
lines = append(lines, accentStyle.Render(" Request Headers:"))
|
||||
headerKeys := make([]string, 0, len(entry.RequestHeaders))
|
||||
for k := range entry.RequestHeaders {
|
||||
headerKeys = append(headerKeys, k)
|
||||
}
|
||||
sort.Strings(headerKeys)
|
||||
for _, k := range headerKeys {
|
||||
v := entry.RequestHeaders[k]
|
||||
// Truncate long header values
|
||||
if len(v) > termWidth-20 {
|
||||
v = v[:termWidth-23] + "..."
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Request body
|
||||
if entry.RequestBody != "" {
|
||||
lines = append(lines, accentStyle.Render(" Request Body:"))
|
||||
// Decompress if needed, then check if binary
|
||||
reqBody := decompressContent(entry.RequestBody, entry.RequestHeaders)
|
||||
if isBinaryContent(reqBody, entry.RequestHeaders) {
|
||||
lines = append(lines, mutedStyle.Render(" [Binary data - not displayed]"))
|
||||
if ct := entry.RequestHeaders["Content-Type"]; ct != "" {
|
||||
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" Content-Type: %s", ct)))
|
||||
}
|
||||
} else {
|
||||
// Format JSON if applicable
|
||||
reqBody = formatJSONContent(reqBody, entry.RequestHeaders)
|
||||
bodyLines := strings.Split(reqBody, "\n")
|
||||
for _, line := range bodyLines {
|
||||
// Truncate long lines
|
||||
if len(line) > termWidth-6 {
|
||||
line = line[:termWidth-9] + "..."
|
||||
}
|
||||
lines = append(lines, " "+line)
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Response summary
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, accentStyle.Render("─── Response ──────────────────────────────────────────"))
|
||||
lines = append(lines, "")
|
||||
|
||||
// Status code with coloring
|
||||
statusStr := fmt.Sprintf("%d", entry.StatusCode)
|
||||
if entry.StatusCode >= 500 {
|
||||
statusStr = errorStyle.Render(statusStr)
|
||||
} else if entry.StatusCode >= 400 {
|
||||
statusStr = warningStyle.Render(statusStr)
|
||||
} else if entry.StatusCode >= 200 && entry.StatusCode < 300 {
|
||||
statusStr = successStyle.Render(statusStr)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" Status: %s", statusStr))
|
||||
|
||||
// Timing
|
||||
latencyStr := ""
|
||||
if entry.LatencyMs >= 1000 {
|
||||
latencyStr = fmt.Sprintf("%.2fs", float64(entry.LatencyMs)/1000)
|
||||
} else {
|
||||
latencyStr = fmt.Sprintf("%dms", entry.LatencyMs)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" Latency: %s", latencyStr))
|
||||
lines = append(lines, fmt.Sprintf(" Body Size: %d bytes", entry.BodySize))
|
||||
lines = append(lines, "")
|
||||
|
||||
// Response headers (sorted alphabetically)
|
||||
if len(entry.ResponseHeaders) > 0 {
|
||||
lines = append(lines, accentStyle.Render(" Response Headers:"))
|
||||
headerKeys := make([]string, 0, len(entry.ResponseHeaders))
|
||||
for k := range entry.ResponseHeaders {
|
||||
headerKeys = append(headerKeys, k)
|
||||
}
|
||||
sort.Strings(headerKeys)
|
||||
for _, k := range headerKeys {
|
||||
v := entry.ResponseHeaders[k]
|
||||
// Truncate long header values
|
||||
if len(v) > termWidth-20 {
|
||||
v = v[:termWidth-23] + "..."
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" %s: %s", mutedStyle.Render(k), v))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Response body
|
||||
if entry.ResponseBody != "" {
|
||||
lines = append(lines, accentStyle.Render(" Response Body:"))
|
||||
// Decompress if needed, then check if binary
|
||||
respBody := decompressContent(entry.ResponseBody, entry.ResponseHeaders)
|
||||
if isBinaryContent(respBody, entry.ResponseHeaders) {
|
||||
lines = append(lines, mutedStyle.Render(" [Binary data - not displayed]"))
|
||||
if ct := entry.ResponseHeaders["Content-Type"]; ct != "" {
|
||||
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" Content-Type: %s", ct)))
|
||||
}
|
||||
} else {
|
||||
// Format JSON if applicable
|
||||
respBody = formatJSONContent(respBody, entry.ResponseHeaders)
|
||||
bodyLines := strings.Split(respBody, "\n")
|
||||
for _, line := range bodyLines {
|
||||
// Truncate long lines
|
||||
if len(line) > termWidth-6 {
|
||||
line = line[:termWidth-9] + "..."
|
||||
}
|
||||
lines = append(lines, " "+line)
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Error if present
|
||||
if entry.Error != "" {
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, errorStyle.Render(" Error: "+entry.Error))
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
// Calculate visible range based on scroll
|
||||
viewportHeight := termHeight - 6 // header, footer, help
|
||||
if viewportHeight < 5 {
|
||||
viewportHeight = 5
|
||||
}
|
||||
|
||||
state := m.ui.httpLogState
|
||||
scroll := state.detailScroll
|
||||
|
||||
// Clamp scroll to valid range
|
||||
maxScroll := len(lines) - viewportHeight
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if scroll > maxScroll {
|
||||
scroll = maxScroll
|
||||
state.detailScroll = scroll
|
||||
}
|
||||
|
||||
// Render visible lines
|
||||
end := scroll + viewportHeight
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
|
||||
for i := scroll; i < end; i++ {
|
||||
b.WriteString(lines[i])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Pad remaining space
|
||||
for i := end - scroll; i < viewportHeight; i++ {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Scroll indicator
|
||||
if len(lines) > viewportHeight {
|
||||
percent := 0
|
||||
if maxScroll > 0 {
|
||||
percent = (scroll * 100) / maxScroll
|
||||
}
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf("\n [%d%%] ", percent)))
|
||||
} else {
|
||||
b.WriteString("\n ")
|
||||
}
|
||||
|
||||
// Show copy message if present, otherwise show help
|
||||
if state.copyMessage != "" {
|
||||
b.WriteString(successStyle.Render(state.copyMessage))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Scroll c: Copy Esc: Back"))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// decompressContent attempts to decompress content based on Content-Encoding header.
|
||||
// Returns the decompressed content if successful, or original content if not compressed or on error.
|
||||
func decompressContent(content string, headers map[string]string) string {
|
||||
enc := headers["Content-Encoding"]
|
||||
if enc == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
data := []byte(content)
|
||||
var reader io.ReadCloser
|
||||
var err error
|
||||
|
||||
switch enc {
|
||||
case "gzip":
|
||||
reader, err = gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return content // Return original on error
|
||||
}
|
||||
defer reader.Close()
|
||||
case "deflate":
|
||||
reader = flate.NewReader(bytes.NewReader(data))
|
||||
defer reader.Close()
|
||||
default:
|
||||
// br (brotli), compress, zstd - not in stdlib, return original
|
||||
return content
|
||||
}
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return content // Return original on error
|
||||
}
|
||||
|
||||
return string(decompressed)
|
||||
}
|
||||
|
||||
// isBinaryContent checks if content is binary and shouldn't be displayed as text
|
||||
func isBinaryContent(content string, headers map[string]string) bool {
|
||||
// Check Content-Type for binary types
|
||||
if ct := headers["Content-Type"]; ct != "" {
|
||||
// Binary content types
|
||||
binaryPrefixes := []string{
|
||||
"image/", "audio/", "video/", "application/octet-stream",
|
||||
"application/zip", "application/gzip", "application/pdf",
|
||||
"application/x-gzip", "application/x-tar", "application/x-bzip",
|
||||
}
|
||||
for _, prefix := range binaryPrefixes {
|
||||
if strings.HasPrefix(ct, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for non-printable characters in the content
|
||||
// If more than 10% of first 200 bytes are non-printable, treat as binary
|
||||
checkLen := len(content)
|
||||
if checkLen > 200 {
|
||||
checkLen = 200
|
||||
}
|
||||
nonPrintable := 0
|
||||
for i := 0; i < checkLen; i++ {
|
||||
c := content[i]
|
||||
// Allow printable ASCII, newline, carriage return, tab
|
||||
if c < 32 && c != '\n' && c != '\r' && c != '\t' {
|
||||
nonPrintable++
|
||||
}
|
||||
// Check for bytes outside ASCII range (common in compressed/binary data)
|
||||
if c > 126 {
|
||||
nonPrintable++
|
||||
}
|
||||
}
|
||||
if checkLen > 0 && float64(nonPrintable)/float64(checkLen) > 0.1 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// formatJSONContent attempts to pretty-print and colorize JSON content.
|
||||
// Returns the formatted JSON if valid, or original content if not JSON.
|
||||
func formatJSONContent(content string, headers map[string]string) string {
|
||||
// Check Content-Type for JSON
|
||||
ct := headers["Content-Type"]
|
||||
isJSON := strings.Contains(ct, "application/json") || strings.Contains(ct, "+json")
|
||||
|
||||
// If not explicitly JSON, try to detect by content
|
||||
if !isJSON {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
// Quick check: must start with { or [ to be JSON
|
||||
if len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '[') {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse and format
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(content), &data); err != nil {
|
||||
return content // Not valid JSON
|
||||
}
|
||||
|
||||
formatted, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
|
||||
// Colorize the formatted JSON
|
||||
return colorizeJSON(string(formatted))
|
||||
}
|
||||
|
||||
// colorizeJSON applies syntax highlighting to formatted JSON.
|
||||
// It processes line by line to handle the indented output from MarshalIndent.
|
||||
func colorizeJSON(jsonStr string) string {
|
||||
var result strings.Builder
|
||||
lines := strings.Split(jsonStr, "\n")
|
||||
|
||||
for i, line := range lines {
|
||||
result.WriteString(colorizeLine(line))
|
||||
if i < len(lines)-1 {
|
||||
result.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// colorizeLine colorizes a single line of formatted JSON
|
||||
func colorizeLine(line string) string {
|
||||
// Find leading whitespace
|
||||
trimmed := strings.TrimLeft(line, " \t")
|
||||
indent := line[:len(line)-len(trimmed)]
|
||||
|
||||
if len(trimmed) == 0 {
|
||||
return line
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(indent)
|
||||
|
||||
// Check for key: value pattern (key starts with ")
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
// Find the end of the key
|
||||
colonIdx := strings.Index(trimmed, "\":")
|
||||
if colonIdx > 0 {
|
||||
// This is a key-value line
|
||||
key := trimmed[:colonIdx+1] // includes the closing quote
|
||||
rest := trimmed[colonIdx+1:]
|
||||
|
||||
// Colorize the key (without quotes for cleaner look, or with - let's keep quotes)
|
||||
result.WriteString(jsonKeyStyle.Render(key))
|
||||
result.WriteString(":")
|
||||
|
||||
// rest starts after the colon
|
||||
if len(rest) > 1 {
|
||||
value := strings.TrimPrefix(rest, " ")
|
||||
hasComma := strings.HasSuffix(value, ",")
|
||||
if hasComma {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
|
||||
result.WriteString(" ")
|
||||
result.WriteString(colorizeValue(value))
|
||||
if hasComma {
|
||||
result.WriteString(",")
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Not a key-value line, could be array element or structural
|
||||
// Check for array values or closing braces
|
||||
hasComma := strings.HasSuffix(trimmed, ",")
|
||||
value := trimmed
|
||||
if hasComma {
|
||||
value = value[:len(value)-1]
|
||||
}
|
||||
|
||||
result.WriteString(colorizeValue(value))
|
||||
if hasComma {
|
||||
result.WriteString(",")
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// colorizeValue colorizes a JSON value (string, number, bool, null, or structural)
|
||||
func colorizeValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
if len(value) == 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
// Structural characters - no color
|
||||
if value == "{" || value == "}" || value == "[" || value == "]" ||
|
||||
value == "{}" || value == "[]" {
|
||||
return value
|
||||
}
|
||||
|
||||
// Null
|
||||
if value == "null" {
|
||||
return jsonNullStyle.Render(value)
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if value == "true" || value == "false" {
|
||||
return jsonBoolStyle.Render(value)
|
||||
}
|
||||
|
||||
// String (starts and ends with quotes)
|
||||
if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
|
||||
return jsonStringStyle.Render(value)
|
||||
}
|
||||
|
||||
// Number (try to detect)
|
||||
if isJSONNumber(value) {
|
||||
return jsonNumberStyle.Render(value)
|
||||
}
|
||||
|
||||
// Unknown - return as is
|
||||
return value
|
||||
}
|
||||
|
||||
// isJSONNumber checks if a string looks like a JSON number
|
||||
func isJSONNumber(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
i := 0
|
||||
// Optional negative sign
|
||||
if s[0] == '-' {
|
||||
i++
|
||||
if i >= len(s) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Must have at least one digit
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip digits
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
|
||||
// Optional decimal part
|
||||
if i < len(s) && s[i] == '.' {
|
||||
i++
|
||||
if i >= len(s) || s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Optional exponent
|
||||
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
|
||||
i++
|
||||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||||
i++
|
||||
}
|
||||
if i >= len(s) || s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return i == len(s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user