mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-12 00:19:24 +00:00
90ddca6709
internal/ui: 27.9% -> 79.8%. Adds three test files (~3300 lines):
- wizard_handlers_extended_test.go: keyboard branches not previously
covered (ctrl+c, pgup/pgdn, search filter typing, port-checked
failure path, alias text edit, focus toggling, error states),
plus removeForwardsCmd / removeForwardByIDCmd against real Mutator
+ tempdir config (dedup + at-least-one-namespace validation).
- wizard_views_test.go: every renderXxx path with a populated
AddWizardState — context/namespace selectors, resource selection,
port entry, http log list/detail, success, scroll indicators.
- table_test.go: AddForward / UpdateStatus / SetError / Clear /
FilterStrings flows.
Test-only fixups noted by agent:
- getFilteredEntries silently skips StatusCode==0 entries (only
completed responses are visible). Tests now seed StatusCode=200.
- handleForwardSaved leaves step=StepConfirmation on save error
rather than advancing to StepSuccess; assertion corrected.
- removeForwardsCmd needs >=2 forwards in a context for dedup tests
so removing one leaves the context non-empty (validator rejects
empty contexts).
go test -race -count=1 ./... clean across all 14 packages.
1468 lines
43 KiB
Go
1468 lines
43 KiB
Go
package ui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/lukaszraczylo/kportal/internal/benchmark"
|
|
"github.com/lukaszraczylo/kportal/internal/config"
|
|
"github.com/lukaszraczylo/kportal/internal/k8s"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newModelWithWizard returns a model with an initialised AddWizardState at the given step.
|
|
func newModelWithWizard(step AddWizardStep) model {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeAddWizard
|
|
w := newAddWizardState()
|
|
w.step = step
|
|
w.selectedContext = "my-ctx"
|
|
w.selectedNamespace = "default"
|
|
w.resourceValue = "my-app"
|
|
w.remotePort = 8080
|
|
w.localPort = 8081
|
|
w.contexts = []string{"my-ctx", "other-ctx"}
|
|
w.namespaces = []string{"default", "kube-system"}
|
|
ui.addWizard = w
|
|
ui.mu.Unlock()
|
|
return model{ui: ui, termWidth: 120, termHeight: 40}
|
|
}
|
|
|
|
// ----- renderAddWizard (dispatch) -----------------------------------------
|
|
|
|
func TestRenderAddWizard_NilWizard(t *testing.T) {
|
|
m := newTestModel()
|
|
result := m.renderAddWizard()
|
|
assert.Empty(t, result)
|
|
}
|
|
|
|
// ----- renderSelectContext ------------------------------------------------
|
|
|
|
func TestRenderSelectContext_Loading(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectContext)
|
|
m.ui.addWizard.loading = true
|
|
result := m.renderSelectContext()
|
|
assert.Contains(t, result, "Loading")
|
|
}
|
|
|
|
func TestRenderSelectContext_Error(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectContext)
|
|
m.ui.addWizard.error = assert.AnError
|
|
result := m.renderSelectContext()
|
|
assert.Contains(t, result, "Error")
|
|
}
|
|
|
|
func TestRenderSelectContext_NoContexts(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectContext)
|
|
m.ui.addWizard.contexts = []string{}
|
|
result := m.renderSelectContext()
|
|
assert.Contains(t, result, "No contexts")
|
|
}
|
|
|
|
func TestRenderSelectContext_WithContexts(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectContext)
|
|
result := m.renderSelectContext()
|
|
assert.Contains(t, result, "my-ctx")
|
|
assert.Contains(t, result, "(current)")
|
|
}
|
|
|
|
func TestRenderSelectContext_WithSearchFilter(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectContext)
|
|
m.ui.addWizard.searchFilter = "my"
|
|
result := m.renderSelectContext()
|
|
assert.Contains(t, result, "Filter: ")
|
|
}
|
|
|
|
func TestRenderSelectContext_FilterNoMatch(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectContext)
|
|
m.ui.addWizard.searchFilter = "zzz"
|
|
result := m.renderSelectContext()
|
|
assert.Contains(t, result, "No matching")
|
|
}
|
|
|
|
func TestRenderSelectContext_ScrollIndicators(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectContext)
|
|
// Create many contexts to trigger scroll.
|
|
many := make([]string, 30)
|
|
for i := range many {
|
|
many[i] = "ctx"
|
|
}
|
|
m.ui.addWizard.contexts = many
|
|
m.ui.addWizard.scrollOffset = 5
|
|
result := m.renderSelectContext()
|
|
assert.Contains(t, result, "↑ More above ↑")
|
|
assert.Contains(t, result, "↓ More below ↓")
|
|
}
|
|
|
|
// ----- renderSelectNamespace ---------------------------------------------
|
|
|
|
func TestRenderSelectNamespace_Loading(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectNamespace)
|
|
m.ui.addWizard.loading = true
|
|
result := m.renderSelectNamespace()
|
|
assert.Contains(t, result, "Loading namespaces")
|
|
}
|
|
|
|
func TestRenderSelectNamespace_Error(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectNamespace)
|
|
m.ui.addWizard.error = assert.AnError
|
|
result := m.renderSelectNamespace()
|
|
assert.Contains(t, result, "Error")
|
|
assert.Contains(t, result, "unreachable")
|
|
}
|
|
|
|
func TestRenderSelectNamespace_NoNamespaces(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectNamespace)
|
|
m.ui.addWizard.namespaces = []string{}
|
|
result := m.renderSelectNamespace()
|
|
assert.Contains(t, result, "No namespaces")
|
|
}
|
|
|
|
func TestRenderSelectNamespace_WithNamespaces(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectNamespace)
|
|
result := m.renderSelectNamespace()
|
|
assert.Contains(t, result, "default")
|
|
}
|
|
|
|
func TestRenderSelectNamespace_FilterNoMatch(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectNamespace)
|
|
m.ui.addWizard.searchFilter = "zzz"
|
|
result := m.renderSelectNamespace()
|
|
assert.Contains(t, result, "No matching")
|
|
}
|
|
|
|
func TestRenderSelectNamespace_WithSearchFilter(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectNamespace)
|
|
m.ui.addWizard.searchFilter = "def"
|
|
result := m.renderSelectNamespace()
|
|
assert.Contains(t, result, "Filter: ")
|
|
}
|
|
|
|
// ----- renderSelectResourceType ------------------------------------------
|
|
|
|
func TestRenderSelectResourceType_AllTypes(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectResourceType)
|
|
result := m.renderSelectResourceType()
|
|
assert.Contains(t, result, "Pod (by name prefix)")
|
|
assert.Contains(t, result, "Pod (by label selector)")
|
|
assert.Contains(t, result, "Service")
|
|
}
|
|
|
|
func TestRenderSelectResourceType_CursorHighlight(t *testing.T) {
|
|
m := newModelWithWizard(StepSelectResourceType)
|
|
m.ui.addWizard.cursor = 1
|
|
result := m.renderSelectResourceType()
|
|
// Description of second type should be shown.
|
|
assert.Contains(t, result, "Flexible")
|
|
}
|
|
|
|
// ----- renderEnterResource -----------------------------------------------
|
|
|
|
func TestRenderEnterResource_PodPrefix_Loading(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
|
|
m.ui.addWizard.loading = true
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "Loading pods")
|
|
}
|
|
|
|
func TestRenderEnterResource_PodPrefix_WithPods(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
|
|
m.ui.addWizard.pods = []k8s.PodInfo{{Name: "my-app-abc"}, {Name: "my-app-def"}}
|
|
m.ui.addWizard.textInput = "my-app"
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "my-app-abc")
|
|
assert.Contains(t, result, "Matches")
|
|
}
|
|
|
|
func TestRenderEnterResource_PodPrefix_NoMatchingPods(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
|
|
m.ui.addWizard.pods = []k8s.PodInfo{{Name: "other-pod"}}
|
|
m.ui.addWizard.textInput = "nomatch"
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "No matching pods")
|
|
}
|
|
|
|
func TestRenderEnterResource_PodPrefix_MorePodsIndicator(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
|
|
pods := make([]k8s.PodInfo, 10)
|
|
for i := range pods {
|
|
pods[i] = k8s.PodInfo{Name: "my-pod"}
|
|
}
|
|
m.ui.addWizard.pods = pods
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "more")
|
|
}
|
|
|
|
func TestRenderEnterResource_PodSelector_WithMatchingPods(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodSelector
|
|
m.ui.addWizard.matchingPods = []k8s.PodInfo{{Name: "pod-1"}, {Name: "pod-2"}, {Name: "pod-3"}, {Name: "pod-4"}}
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "Found 4 matching")
|
|
}
|
|
|
|
func TestRenderEnterResource_PodSelector_Error(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodSelector
|
|
m.ui.addWizard.error = assert.AnError
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "Invalid selector")
|
|
}
|
|
|
|
func TestRenderEnterResource_PodSelector_Loading(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodSelector
|
|
m.ui.addWizard.loading = true
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "Validating")
|
|
}
|
|
|
|
func TestRenderEnterResource_Service_Loading(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypeService
|
|
m.ui.addWizard.loading = true
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "Loading services")
|
|
}
|
|
|
|
func TestRenderEnterResource_Service_Empty(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypeService
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "No services")
|
|
}
|
|
|
|
func TestRenderEnterResource_Service_WithServices(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypeService
|
|
m.ui.addWizard.services = []k8s.ServiceInfo{{Name: "api-svc"}, {Name: "db-svc"}}
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "api-svc")
|
|
}
|
|
|
|
func TestRenderEnterResource_Service_FilterNoMatch(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypeService
|
|
m.ui.addWizard.services = []k8s.ServiceInfo{{Name: "api-svc"}}
|
|
m.ui.addWizard.searchFilter = "zzz"
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "No matching services")
|
|
}
|
|
|
|
func TestRenderEnterResource_Service_WithSearchFilter(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterResource)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypeService
|
|
m.ui.addWizard.services = []k8s.ServiceInfo{{Name: "api-svc"}}
|
|
m.ui.addWizard.searchFilter = "api"
|
|
result := m.renderEnterResource()
|
|
assert.Contains(t, result, "Filter: ")
|
|
}
|
|
|
|
// ----- renderEnterRemotePort ---------------------------------------------
|
|
|
|
func TestRenderEnterRemotePort_TextMode(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterRemotePort)
|
|
m.ui.addWizard.inputMode = InputModeText
|
|
result := m.renderEnterRemotePort()
|
|
assert.Contains(t, result, "Remote port:")
|
|
}
|
|
|
|
func TestRenderEnterRemotePort_TextMode_Error(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterRemotePort)
|
|
m.ui.addWizard.inputMode = InputModeText
|
|
m.ui.addWizard.error = assert.AnError
|
|
result := m.renderEnterRemotePort()
|
|
assert.Contains(t, result, "✗")
|
|
}
|
|
|
|
func TestRenderEnterRemotePort_TextMode_WithInput(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterRemotePort)
|
|
m.ui.addWizard.inputMode = InputModeText
|
|
m.ui.addWizard.textInput = "8080"
|
|
result := m.renderEnterRemotePort()
|
|
assert.Contains(t, result, "Press Enter")
|
|
}
|
|
|
|
func TestRenderEnterRemotePort_ListMode_WithPorts(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterRemotePort)
|
|
m.ui.addWizard.inputMode = InputModeList
|
|
m.ui.addWizard.detectedPorts = []k8s.PortInfo{
|
|
{Port: 80, Name: "http"},
|
|
{Port: 8080, TargetPort: 9090, Name: "grpc"},
|
|
}
|
|
result := m.renderEnterRemotePort()
|
|
assert.Contains(t, result, "Manual entry")
|
|
assert.Contains(t, result, "80")
|
|
}
|
|
|
|
func TestRenderEnterRemotePort_ListMode_ScrollIndicators(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterRemotePort)
|
|
m.ui.addWizard.inputMode = InputModeList
|
|
ports := make([]k8s.PortInfo, 25)
|
|
for i := range ports {
|
|
ports[i] = k8s.PortInfo{Port: int32(8000 + i)} //nolint:gosec // safe: i ≤ 24, no overflow
|
|
}
|
|
m.ui.addWizard.detectedPorts = ports
|
|
m.ui.addWizard.scrollOffset = 3
|
|
result := m.renderEnterRemotePort()
|
|
assert.Contains(t, result, "↑ More above ↑")
|
|
}
|
|
|
|
func TestRenderEnterRemotePort_TextMode_DetectedPortsShown(t *testing.T) {
|
|
// Text mode but with detected ports — shows them as reference.
|
|
m := newModelWithWizard(StepEnterRemotePort)
|
|
m.ui.addWizard.inputMode = InputModeText
|
|
m.ui.addWizard.detectedPorts = []k8s.PortInfo{{Port: 80}}
|
|
result := m.renderEnterRemotePort()
|
|
assert.Contains(t, result, "Detected ports")
|
|
}
|
|
|
|
func TestRenderEnterRemotePort_Selector(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterRemotePort)
|
|
m.ui.addWizard.inputMode = InputModeText
|
|
m.ui.addWizard.selector = "app=my-app"
|
|
result := m.renderEnterRemotePort()
|
|
assert.Contains(t, result, "app=my-app")
|
|
}
|
|
|
|
// ----- renderEnterLocalPort ----------------------------------------------
|
|
|
|
func TestRenderEnterLocalPort_Loading(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterLocalPort)
|
|
m.ui.addWizard.loading = true
|
|
result := m.renderEnterLocalPort()
|
|
assert.Contains(t, result, "Checking availability")
|
|
}
|
|
|
|
func TestRenderEnterLocalPort_Error(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterLocalPort)
|
|
m.ui.addWizard.error = assert.AnError
|
|
result := m.renderEnterLocalPort()
|
|
assert.Contains(t, result, "✗")
|
|
}
|
|
|
|
func TestRenderEnterLocalPort_PortAvailable(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterLocalPort)
|
|
m.ui.addWizard.portAvailable = true
|
|
m.ui.addWizard.portCheckMsg = "✓ Port 8081 available"
|
|
result := m.renderEnterLocalPort()
|
|
assert.Contains(t, result, "✓")
|
|
}
|
|
|
|
func TestRenderEnterLocalPort_PortUnavailable(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterLocalPort)
|
|
m.ui.addWizard.portAvailable = false
|
|
m.ui.addWizard.portCheckMsg = "✗ Port 8081 in use"
|
|
result := m.renderEnterLocalPort()
|
|
assert.Contains(t, result, "in use")
|
|
}
|
|
|
|
func TestRenderEnterLocalPort_WithInput(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterLocalPort)
|
|
m.ui.addWizard.textInput = "8081"
|
|
result := m.renderEnterLocalPort()
|
|
assert.Contains(t, result, "Press Enter")
|
|
}
|
|
|
|
func TestRenderEnterLocalPort_WithSelector(t *testing.T) {
|
|
m := newModelWithWizard(StepEnterLocalPort)
|
|
m.ui.addWizard.selector = "app=db"
|
|
result := m.renderEnterLocalPort()
|
|
assert.Contains(t, result, "app=db")
|
|
}
|
|
|
|
// ----- renderConfirmation ------------------------------------------------
|
|
|
|
func TestRenderConfirmation_DefaultView(t *testing.T) {
|
|
m := newModelWithWizard(StepConfirmation)
|
|
m.ui.addWizard.confirmationFocus = FocusButtons
|
|
m.ui.addWizard.cursor = 0
|
|
result := m.renderConfirmation()
|
|
assert.Contains(t, result, "Add to .kportal.yaml")
|
|
assert.Contains(t, result, "my-ctx")
|
|
assert.Contains(t, result, "default")
|
|
}
|
|
|
|
func TestRenderConfirmation_AliasFocus(t *testing.T) {
|
|
m := newModelWithWizard(StepConfirmation)
|
|
m.ui.addWizard.confirmationFocus = FocusAlias
|
|
result := m.renderConfirmation()
|
|
assert.Contains(t, result, "█") // cursor in alias field
|
|
}
|
|
|
|
func TestRenderConfirmation_ButtonsCancelSelected(t *testing.T) {
|
|
m := newModelWithWizard(StepConfirmation)
|
|
m.ui.addWizard.confirmationFocus = FocusButtons
|
|
m.ui.addWizard.cursor = 1 // Cancel
|
|
result := m.renderConfirmation()
|
|
assert.Contains(t, result, "Cancel")
|
|
}
|
|
|
|
func TestRenderConfirmation_HTTPLogEnabled(t *testing.T) {
|
|
m := newModelWithWizard(StepConfirmation)
|
|
m.ui.addWizard.httpLog = true
|
|
result := m.renderConfirmation()
|
|
assert.Contains(t, result, "[x] enabled")
|
|
}
|
|
|
|
func TestRenderConfirmation_HTTPLogDisabled(t *testing.T) {
|
|
m := newModelWithWizard(StepConfirmation)
|
|
m.ui.addWizard.httpLog = false
|
|
result := m.renderConfirmation()
|
|
assert.Contains(t, result, "[ ] disabled")
|
|
}
|
|
|
|
func TestRenderConfirmation_SelectorResource(t *testing.T) {
|
|
m := newModelWithWizard(StepConfirmation)
|
|
m.ui.addWizard.selector = "app=myapp"
|
|
result := m.renderConfirmation()
|
|
assert.Contains(t, result, "selector")
|
|
}
|
|
|
|
func TestRenderConfirmation_ServiceResource(t *testing.T) {
|
|
m := newModelWithWizard(StepConfirmation)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypeService
|
|
result := m.renderConfirmation()
|
|
assert.Contains(t, result, "service/")
|
|
}
|
|
|
|
func TestRenderConfirmation_PodPrefixResource(t *testing.T) {
|
|
m := newModelWithWizard(StepConfirmation)
|
|
m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix
|
|
result := m.renderConfirmation()
|
|
assert.Contains(t, result, "pod/")
|
|
}
|
|
|
|
// ----- renderSuccess -----------------------------------------------------
|
|
|
|
func TestRenderSuccess_Success(t *testing.T) {
|
|
m := newModelWithWizard(StepSuccess)
|
|
m.ui.addWizard.alias = "my-alias"
|
|
result := m.renderSuccess()
|
|
assert.Contains(t, result, "Success")
|
|
assert.Contains(t, result, "Add another")
|
|
assert.Contains(t, result, "Return to main view")
|
|
}
|
|
|
|
func TestRenderSuccess_Error(t *testing.T) {
|
|
m := newModelWithWizard(StepSuccess)
|
|
m.ui.addWizard.error = assert.AnError
|
|
result := m.renderSuccess()
|
|
assert.Contains(t, result, "Error")
|
|
}
|
|
|
|
func TestRenderSuccess_CursorOnReturn(t *testing.T) {
|
|
m := newModelWithWizard(StepSuccess)
|
|
m.ui.addWizard.cursor = 1
|
|
result := m.renderSuccess()
|
|
assert.Contains(t, result, "▸ Return to main view")
|
|
}
|
|
|
|
func TestRenderSuccess_WithAlias(t *testing.T) {
|
|
m := newModelWithWizard(StepSuccess)
|
|
m.ui.addWizard.alias = "my-svc"
|
|
result := m.renderSuccess()
|
|
assert.Contains(t, result, "my-svc")
|
|
}
|
|
|
|
// ----- renderAddWizard dispatch ------------------------------------------
|
|
|
|
func TestRenderAddWizard_UnknownStep(t *testing.T) {
|
|
m := newModelWithWizard(AddWizardStep(99))
|
|
result := m.renderAddWizard()
|
|
assert.Contains(t, result, "Unknown step")
|
|
}
|
|
|
|
// ----- renderRemoveWizard ------------------------------------------------
|
|
|
|
func TestRenderRemoveWizard_NilWizard(t *testing.T) {
|
|
m := newTestModel()
|
|
result := m.renderRemoveWizard()
|
|
assert.Empty(t, result)
|
|
}
|
|
|
|
func TestRenderRemoveSelection_ShowsForwards(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeRemoveWizard
|
|
ui.removeWizard = &RemoveWizardState{
|
|
forwards: []RemovableForward{
|
|
{ID: "f1", Alias: "alpha", Port: 80, LocalPort: 8080, Context: "ctx", Namespace: "ns", Resource: "pod/alpha"},
|
|
{ID: "f2", Alias: "beta", Port: 81, LocalPort: 8081, Context: "ctx", Namespace: "ns", Resource: "pod/beta"},
|
|
},
|
|
selected: map[int]bool{0: true},
|
|
cursor: 0,
|
|
}
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderRemoveSelection()
|
|
assert.Contains(t, result, "alpha")
|
|
assert.Contains(t, result, "beta")
|
|
assert.Contains(t, result, "[✓]")
|
|
assert.Contains(t, result, "1 of 2 selected")
|
|
}
|
|
|
|
func TestRenderRemoveConfirmation_Shows(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeRemoveWizard
|
|
ui.removeWizard = &RemoveWizardState{
|
|
forwards: []RemovableForward{
|
|
{ID: "f1", Alias: "alpha", Port: 80, LocalPort: 8080, Context: "ctx", Namespace: "ns", Resource: "pod/alpha"},
|
|
},
|
|
selected: map[int]bool{0: true},
|
|
confirming: true,
|
|
}
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderRemoveConfirmation()
|
|
assert.Contains(t, result, "alpha")
|
|
assert.Contains(t, result, "Yes, remove")
|
|
assert.Contains(t, result, "cannot be undone")
|
|
}
|
|
|
|
func TestRenderRemoveConfirmation_CancelSelected(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeRemoveWizard
|
|
ui.removeWizard = &RemoveWizardState{
|
|
forwards: []RemovableForward{{ID: "f1", Alias: "alpha"}},
|
|
selected: map[int]bool{0: true},
|
|
confirming: true,
|
|
confirmCursor: 1,
|
|
}
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderRemoveConfirmation()
|
|
assert.Contains(t, result, "▸ Cancel")
|
|
}
|
|
|
|
// ----- renderBenchmark ---------------------------------------------------
|
|
|
|
func TestRenderBenchmark_NilState(t *testing.T) {
|
|
m := newTestModel()
|
|
result := m.renderBenchmark()
|
|
assert.Empty(t, result)
|
|
}
|
|
|
|
func TestRenderBenchmarkConfig(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeBenchmark
|
|
ui.benchmarkState = newBenchmarkState("fwd-id", "my-svc", 8080)
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderBenchmarkConfig()
|
|
assert.Contains(t, result, "URL Path")
|
|
assert.Contains(t, result, "Method")
|
|
assert.Contains(t, result, "Concurrency")
|
|
assert.Contains(t, result, "Requests")
|
|
assert.Contains(t, result, "my-svc")
|
|
}
|
|
|
|
func TestRenderBenchmarkRunning(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeBenchmark
|
|
state := newBenchmarkState("fwd-id", "my-svc", 8080)
|
|
state.step = BenchmarkStepRunning
|
|
state.running = true
|
|
state.progress = 30
|
|
state.total = 100
|
|
ui.benchmarkState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderBenchmarkRunning()
|
|
assert.Contains(t, result, "Running")
|
|
assert.Contains(t, result, "30")
|
|
assert.Contains(t, result, "100")
|
|
}
|
|
|
|
func TestRenderBenchmarkResults_WithError(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeBenchmark
|
|
state := newBenchmarkState("fwd-id", "my-svc", 8080)
|
|
state.step = BenchmarkStepResults
|
|
state.error = assert.AnError
|
|
ui.benchmarkState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderBenchmarkResults()
|
|
assert.Contains(t, result, "Error")
|
|
}
|
|
|
|
func TestRenderBenchmarkResults_NilResults(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeBenchmark
|
|
state := newBenchmarkState("fwd-id", "my-svc", 8080)
|
|
state.step = BenchmarkStepResults
|
|
ui.benchmarkState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderBenchmarkResults()
|
|
assert.Contains(t, result, "No results")
|
|
}
|
|
|
|
func TestRenderBenchmarkResults_WithResults(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeBenchmark
|
|
state := newBenchmarkState("fwd-id", "my-svc", 8080)
|
|
state.step = BenchmarkStepResults
|
|
state.results = &BenchmarkResults{
|
|
TotalRequests: 100,
|
|
Successful: 95,
|
|
Failed: 5,
|
|
MinLatency: 1.0,
|
|
MaxLatency: 50.0,
|
|
AvgLatency: 10.0,
|
|
P50Latency: 8.0,
|
|
P95Latency: 40.0,
|
|
P99Latency: 48.0,
|
|
Throughput: 50.0,
|
|
BytesRead: 10240,
|
|
StatusCodes: map[int]int{200: 95, 500: 5},
|
|
}
|
|
ui.benchmarkState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderBenchmarkResults()
|
|
assert.Contains(t, result, "Total Requests: 100")
|
|
assert.Contains(t, result, "Successful:")
|
|
assert.Contains(t, result, "Failed:")
|
|
assert.Contains(t, result, "P95")
|
|
assert.Contains(t, result, "Status Codes")
|
|
}
|
|
|
|
func TestRenderBenchmark_Dispatch(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeBenchmark
|
|
state := newBenchmarkState("fwd-id", "my-svc", 8080)
|
|
state.step = BenchmarkStepConfig
|
|
ui.benchmarkState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderBenchmark()
|
|
require.NotEmpty(t, result)
|
|
assert.Contains(t, result, "URL Path")
|
|
}
|
|
|
|
// ----- renderHTTPLog -----------------------------------------------------
|
|
|
|
func TestRenderHTTPLog_NilState(t *testing.T) {
|
|
m := newTestModel()
|
|
result := m.renderHTTPLog()
|
|
assert.Empty(t, result)
|
|
}
|
|
|
|
func TestRenderHTTPLog_NoEntries(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
ui.httpLogState = newHTTPLogState("fwd-id", "my-svc")
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderHTTPLog()
|
|
assert.Contains(t, result, "No HTTP traffic")
|
|
}
|
|
|
|
func TestRenderHTTPLog_WithEntries(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
state.entries = []HTTPLogEntry{
|
|
{Method: "GET", Path: "/api/test", StatusCode: 200, Timestamp: "12:00:00"},
|
|
{Method: "POST", Path: "/api/create", StatusCode: 500, Timestamp: "12:00:01"},
|
|
{Method: "PUT", Path: "/api/update", StatusCode: 404, Timestamp: "12:00:02"},
|
|
}
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderHTTPLog()
|
|
assert.Contains(t, result, "GET")
|
|
assert.Contains(t, result, "/api/test")
|
|
}
|
|
|
|
func TestRenderHTTPLog_FilterActive(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
state.filterActive = true
|
|
state.filterText = "test"
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderHTTPLog()
|
|
assert.Contains(t, result, "Search: ")
|
|
}
|
|
|
|
func TestRenderHTTPLog_FilteredEntries(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
state.entries = []HTTPLogEntry{
|
|
{Method: "GET", Path: "/api/test", StatusCode: 200},
|
|
}
|
|
state.filterMode = HTTPLogFilterNon200
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderHTTPLog()
|
|
// With Non200 filter active and only 200s, no entries match.
|
|
assert.Contains(t, result, "No entries match filter")
|
|
}
|
|
|
|
func TestRenderHTTPLog_AutoScrollIndicator(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
state.autoScroll = true
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderHTTPLog()
|
|
assert.Contains(t, result, "Auto-scroll")
|
|
}
|
|
|
|
func TestRenderHTTPLog_FilterIndicator(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
state.filterMode = HTTPLogFilterErrors
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderHTTPLog()
|
|
assert.Contains(t, result, "Filter:")
|
|
}
|
|
|
|
func TestRenderHTTPLog_DetailView(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
state.entries = []HTTPLogEntry{
|
|
{
|
|
Method: "GET",
|
|
Path: "/detail",
|
|
StatusCode: 200,
|
|
LatencyMs: 100,
|
|
RequestHeaders: map[string]string{
|
|
"Content-Type": "application/json",
|
|
},
|
|
ResponseHeaders: map[string]string{
|
|
"X-Request-ID": "abc123",
|
|
},
|
|
RequestBody: `{"key": "value"}`,
|
|
ResponseBody: `{"result": "ok"}`,
|
|
},
|
|
}
|
|
state.cursor = 0
|
|
state.showingDetail = true
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderHTTPLog()
|
|
assert.Contains(t, result, "Request Detail")
|
|
}
|
|
|
|
// ----- renderHTTPLogDetail -----------------------------------------------
|
|
|
|
func TestRenderHTTPLogDetail_HighLatency(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
entry := HTTPLogEntry{
|
|
Method: "GET",
|
|
Path: "/slow",
|
|
LatencyMs: 2500, // >1000ms → should display as seconds
|
|
}
|
|
result := m.renderHTTPLogDetail(entry, 120, 40)
|
|
assert.Contains(t, result, "2.50s")
|
|
}
|
|
|
|
func TestRenderHTTPLogDetail_Status500(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
entry := HTTPLogEntry{Method: "POST", Path: "/fail", StatusCode: 500}
|
|
result := m.renderHTTPLogDetail(entry, 120, 40)
|
|
assert.Contains(t, result, "500")
|
|
}
|
|
|
|
func TestRenderHTTPLogDetail_Status400(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
entry := HTTPLogEntry{Method: "GET", Path: "/notfound", StatusCode: 404}
|
|
result := m.renderHTTPLogDetail(entry, 120, 40)
|
|
assert.Contains(t, result, "404")
|
|
}
|
|
|
|
func TestRenderHTTPLogDetail_WithError(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
entry := HTTPLogEntry{Method: "GET", Path: "/err", Error: "connection reset"}
|
|
result := m.renderHTTPLogDetail(entry, 120, 40)
|
|
assert.Contains(t, result, "connection reset")
|
|
}
|
|
|
|
func TestRenderHTTPLogDetail_BinaryRequestBody(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
entry := HTTPLogEntry{
|
|
Method: "POST",
|
|
Path: "/upload",
|
|
RequestBody: "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a",
|
|
RequestHeaders: map[string]string{
|
|
"Content-Type": "application/octet-stream",
|
|
},
|
|
}
|
|
result := m.renderHTTPLogDetail(entry, 120, 40)
|
|
assert.Contains(t, result, "Binary data")
|
|
}
|
|
|
|
func TestRenderHTTPLogDetail_CopyMessage(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
state.copyMessage = "Copied!"
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
entry := HTTPLogEntry{Method: "GET", Path: "/x"}
|
|
result := m.renderHTTPLogDetail(entry, 120, 40)
|
|
assert.Contains(t, result, "Copied!")
|
|
}
|
|
|
|
func TestRenderHTTPLogDetail_ScrollIndicator(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
state := newHTTPLogState("fwd-id", "my-svc")
|
|
// Force a long response body to make content exceed viewport.
|
|
bodyLines := strings.Repeat("a line of body content\n", 100)
|
|
state.entries = []HTTPLogEntry{{
|
|
Method: "GET",
|
|
Path: "/long",
|
|
StatusCode: 200,
|
|
ResponseBody: bodyLines,
|
|
ResponseHeaders: map[string]string{
|
|
"Content-Type": "text/plain",
|
|
},
|
|
}}
|
|
state.detailScroll = 5
|
|
ui.httpLogState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 10}
|
|
|
|
entry := state.entries[0]
|
|
result := m.renderHTTPLogDetail(entry, 120, 10)
|
|
// Should contain a scroll percentage indicator.
|
|
assert.Contains(t, result, "%")
|
|
}
|
|
|
|
// ----- renderMainView helpers -------------------------------------------
|
|
|
|
func TestRenderMainView_EmptyForwards(t *testing.T) {
|
|
m := newTestModel()
|
|
result := m.renderMainView()
|
|
assert.Contains(t, result, "No forwards configured")
|
|
}
|
|
|
|
func TestRenderMainView_WithForwards(t *testing.T) {
|
|
m := newTestModelWithForward()
|
|
result := m.renderMainView()
|
|
assert.Contains(t, result, "my-app")
|
|
assert.Contains(t, result, "CONTEXT")
|
|
assert.Contains(t, result, "STATUS")
|
|
}
|
|
|
|
func TestRenderMainView_WithErrors(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("id-1", fwd)
|
|
ui.SetError("id-1", "connection refused")
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderMainView()
|
|
assert.Contains(t, result, "Errors:")
|
|
assert.Contains(t, result, "connection refused")
|
|
}
|
|
|
|
func TestRenderMainView_UpdateAvailable(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.SetUpdateAvailable("2.0.0", "http://example.com")
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderMainView()
|
|
assert.Contains(t, result, "Update available")
|
|
assert.Contains(t, result, "2.0.0")
|
|
}
|
|
|
|
func TestRenderDeleteConfirmation_YesSelected(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.deleteConfirming = true
|
|
ui.deleteConfirmID = "id-1"
|
|
ui.deleteConfirmAlias = "my-service"
|
|
ui.deleteConfirmCursor = 0 // Yes
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderDeleteConfirmation()
|
|
assert.Contains(t, result, "my-service")
|
|
assert.Contains(t, result, "Yes")
|
|
assert.Contains(t, result, "No")
|
|
}
|
|
|
|
func TestRenderDeleteConfirmation_NoSelected(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.deleteConfirming = true
|
|
ui.deleteConfirmAlias = "my-service"
|
|
ui.deleteConfirmCursor = 1 // No
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.renderDeleteConfirmation()
|
|
assert.Contains(t, result, "Yes")
|
|
assert.Contains(t, result, "No")
|
|
}
|
|
|
|
// ----- View() dispatch --------------------------------------------------
|
|
|
|
func TestModelView_MainViewDefault(t *testing.T) {
|
|
m := newTestModel()
|
|
result := m.View()
|
|
assert.NotEmpty(t, result)
|
|
}
|
|
|
|
func TestModelView_DeleteConfirmationOverlay(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"}
|
|
ui.AddForward("id-1", fwd)
|
|
ui.mu.Lock()
|
|
ui.deleteConfirming = true
|
|
ui.deleteConfirmAlias = "app"
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.View()
|
|
assert.Contains(t, result, "Delete Port Forward")
|
|
}
|
|
|
|
func TestModelView_AddWizardOverlay(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}
|
|
|
|
result := m.View()
|
|
assert.NotEmpty(t, result)
|
|
}
|
|
|
|
func TestModelView_RemoveWizardOverlay(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeRemoveWizard
|
|
ui.removeWizard = &RemoveWizardState{
|
|
forwards: []RemovableForward{{ID: "f1", Alias: "alpha"}},
|
|
selected: map[int]bool{},
|
|
}
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.View()
|
|
assert.Contains(t, result, "alpha")
|
|
}
|
|
|
|
func TestModelView_BenchmarkOverlay(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeBenchmark
|
|
ui.benchmarkState = newBenchmarkState("fwd-id", "my-svc", 8080)
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.View()
|
|
assert.NotEmpty(t, result)
|
|
}
|
|
|
|
func TestModelView_HTTPLogFullScreen(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeHTTPLog
|
|
ui.httpLogState = newHTTPLogState("fwd-id", "my-svc")
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
result := m.View()
|
|
assert.Contains(t, result, "HTTP Traffic Log")
|
|
}
|
|
|
|
func TestModelView_ZeroTermSize(t *testing.T) {
|
|
m := newTestModel()
|
|
m.termWidth = 0
|
|
m.termHeight = 0
|
|
// Must not panic even with zero term dims.
|
|
result := m.View()
|
|
assert.NotEmpty(t, result)
|
|
}
|
|
|
|
// ----- decompressContent -------------------------------------------------
|
|
|
|
func TestDecompressContent_NoEncoding(t *testing.T) {
|
|
content := "hello world"
|
|
result := decompressContent(content, map[string]string{})
|
|
assert.Equal(t, content, result)
|
|
}
|
|
|
|
func TestDecompressContent_UnknownEncoding(t *testing.T) {
|
|
content := "hello world"
|
|
result := decompressContent(content, map[string]string{"Content-Encoding": "br"})
|
|
assert.Equal(t, content, result)
|
|
}
|
|
|
|
func TestDecompressContent_InvalidGzip(t *testing.T) {
|
|
content := "not-gzip-data"
|
|
result := decompressContent(content, map[string]string{"Content-Encoding": "gzip"})
|
|
assert.Equal(t, content, result)
|
|
}
|
|
|
|
func TestDecompressContent_InvalidDeflate(t *testing.T) {
|
|
content := "not-deflate-data"
|
|
result := decompressContent(content, map[string]string{"Content-Encoding": "deflate"})
|
|
// Should return original since invalid deflate data.
|
|
assert.NotEmpty(t, result)
|
|
}
|
|
|
|
// ----- isBinaryContent ---------------------------------------------------
|
|
|
|
func TestIsBinaryContent_ImageContentType(t *testing.T) {
|
|
result := isBinaryContent("data", map[string]string{"Content-Type": "image/png"})
|
|
assert.True(t, result)
|
|
}
|
|
|
|
func TestIsBinaryContent_AudioContentType(t *testing.T) {
|
|
result := isBinaryContent("data", map[string]string{"Content-Type": "audio/mp3"})
|
|
assert.True(t, result)
|
|
}
|
|
|
|
func TestIsBinaryContent_OctetStream(t *testing.T) {
|
|
result := isBinaryContent("data", map[string]string{"Content-Type": "application/octet-stream"})
|
|
assert.True(t, result)
|
|
}
|
|
|
|
func TestIsBinaryContent_TextContent(t *testing.T) {
|
|
result := isBinaryContent("hello world", map[string]string{"Content-Type": "text/plain"})
|
|
assert.False(t, result)
|
|
}
|
|
|
|
func TestIsBinaryContent_BinaryBytes(t *testing.T) {
|
|
// Many non-printable bytes should be detected as binary.
|
|
binary := strings.Repeat("\x00\x01\x02", 50)
|
|
result := isBinaryContent(binary, map[string]string{})
|
|
assert.True(t, result)
|
|
}
|
|
|
|
func TestIsBinaryContent_EmptyContent(t *testing.T) {
|
|
result := isBinaryContent("", map[string]string{})
|
|
assert.False(t, result)
|
|
}
|
|
|
|
// ----- formatJSONContent ------------------------------------------------
|
|
|
|
func TestFormatJSONContent_ValidJSON(t *testing.T) {
|
|
json := `{"key":"value","num":42}`
|
|
result := formatJSONContent(json, map[string]string{"Content-Type": "application/json"})
|
|
assert.Contains(t, result, "key")
|
|
assert.Contains(t, result, "value")
|
|
}
|
|
|
|
func TestFormatJSONContent_NotJSON(t *testing.T) {
|
|
text := "plain text content"
|
|
result := formatJSONContent(text, map[string]string{"Content-Type": "text/plain"})
|
|
assert.Equal(t, text, result)
|
|
}
|
|
|
|
func TestFormatJSONContent_InvalidJSON(t *testing.T) {
|
|
bad := `{"key": invalid}`
|
|
result := formatJSONContent(bad, map[string]string{"Content-Type": "application/json"})
|
|
assert.Equal(t, bad, result)
|
|
}
|
|
|
|
func TestFormatJSONContent_AutoDetect(t *testing.T) {
|
|
json := `{"auto": true}`
|
|
result := formatJSONContent(json, map[string]string{})
|
|
assert.Contains(t, result, "auto")
|
|
}
|
|
|
|
func TestFormatJSONContent_ArrayJSON(t *testing.T) {
|
|
json := `[1, 2, 3]`
|
|
result := formatJSONContent(json, map[string]string{})
|
|
assert.Contains(t, result, "1")
|
|
}
|
|
|
|
func TestFormatJSONContent_JSONPlusType(t *testing.T) {
|
|
json := `{"ok":true}`
|
|
result := formatJSONContent(json, map[string]string{"Content-Type": "application/vnd.api+json"})
|
|
assert.Contains(t, result, "ok")
|
|
}
|
|
|
|
// ----- colorizeJSON / colorizeLine / colorizeValue / isJSONNumber --------
|
|
|
|
func TestColorizeJSON_RoundTrip(t *testing.T) {
|
|
json := `{
|
|
"key": "value",
|
|
"num": 42,
|
|
"flag": true,
|
|
"nothing": null,
|
|
"arr": []
|
|
}`
|
|
result := colorizeJSON(json)
|
|
assert.Contains(t, result, "key")
|
|
assert.Contains(t, result, "value")
|
|
assert.Contains(t, result, "42")
|
|
}
|
|
|
|
func TestColorizeValue_Null(t *testing.T) {
|
|
result := colorizeValue("null")
|
|
assert.Contains(t, result, "null")
|
|
}
|
|
|
|
func TestColorizeValue_Bool(t *testing.T) {
|
|
assert.Contains(t, colorizeValue("true"), "true")
|
|
assert.Contains(t, colorizeValue("false"), "false")
|
|
}
|
|
|
|
func TestColorizeValue_String(t *testing.T) {
|
|
result := colorizeValue(`"hello"`)
|
|
assert.Contains(t, result, "hello")
|
|
}
|
|
|
|
func TestColorizeValue_Number(t *testing.T) {
|
|
result := colorizeValue("42")
|
|
assert.Contains(t, result, "42")
|
|
}
|
|
|
|
func TestColorizeValue_Structural(t *testing.T) {
|
|
for _, v := range []string{"{", "}", "[", "]", "{}", "[]"} {
|
|
assert.Equal(t, v, colorizeValue(v))
|
|
}
|
|
}
|
|
|
|
func TestColorizeValue_Unknown(t *testing.T) {
|
|
result := colorizeValue("weird_value")
|
|
assert.Equal(t, "weird_value", result)
|
|
}
|
|
|
|
func TestColorizeValue_Empty(t *testing.T) {
|
|
result := colorizeValue("")
|
|
assert.Equal(t, "", result)
|
|
}
|
|
|
|
func TestIsJSONNumber(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected bool
|
|
}{
|
|
{"42", true},
|
|
{"3.14", true},
|
|
{"-5", true},
|
|
{"1e10", true},
|
|
{"1.5E-3", true},
|
|
{"", false},
|
|
{"-", false},
|
|
{"abc", false},
|
|
{"1.2.3", false},
|
|
{"1e", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
assert.Equal(t, tt.expected, isJSONNumber(tt.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
// ----- renderStyles helpers ----------------------------------------------
|
|
|
|
func TestRenderProgress(t *testing.T) {
|
|
result := renderProgress(3, 7)
|
|
assert.Contains(t, result, "3")
|
|
assert.Contains(t, result, "7")
|
|
}
|
|
|
|
func TestRenderHeader(t *testing.T) {
|
|
result := renderHeader("My Title", "Step 1/5")
|
|
assert.Contains(t, result, "My Title")
|
|
assert.Contains(t, result, "Step 1/5")
|
|
}
|
|
|
|
func TestRenderHeader_NoProgress(t *testing.T) {
|
|
result := renderHeader("Title Only", "")
|
|
assert.Contains(t, result, "Title Only")
|
|
}
|
|
|
|
func TestRenderBreadcrumb(t *testing.T) {
|
|
result := renderBreadcrumb("ctx", "ns")
|
|
assert.Contains(t, result, "ctx")
|
|
assert.Contains(t, result, "ns")
|
|
}
|
|
|
|
func TestRenderList(t *testing.T) {
|
|
items := []string{"alpha", "beta", "gamma"}
|
|
result := renderList(items, 1, " ", 0)
|
|
assert.Contains(t, result, "alpha")
|
|
assert.Contains(t, result, "beta")
|
|
assert.Contains(t, result, "gamma")
|
|
assert.Contains(t, result, "▸") // cursor on item 1
|
|
}
|
|
|
|
func TestRenderList_ScrollIndicators(t *testing.T) {
|
|
// Need offset > 0 for the "above" indicator and offset+ViewportHeight < totalItems for the "below" indicator.
|
|
// ViewportHeight = 20, so with offset=5 we need totalItems > 25.
|
|
items := make([]string, 27)
|
|
for i := range items {
|
|
items[i] = "item"
|
|
}
|
|
// cursor=22 is within the visible window [5,25), scrollOffset=5.
|
|
// end = 5+20 = 25 < 27, so "↓ More below ↓" is rendered.
|
|
result := renderList(items, 22, " ", 5)
|
|
assert.Contains(t, result, "↑ More above ↑")
|
|
assert.Contains(t, result, "↓ More below ↓")
|
|
}
|
|
|
|
func TestRenderTextInput_Valid(t *testing.T) {
|
|
result := renderTextInput("Label: ", "hello", true)
|
|
assert.Contains(t, result, "Label: ")
|
|
assert.Contains(t, result, "hello")
|
|
assert.Contains(t, result, "█")
|
|
}
|
|
|
|
func TestRenderTextInput_Invalid(t *testing.T) {
|
|
result := renderTextInput("Port: ", "abc", false)
|
|
assert.Contains(t, result, "abc")
|
|
}
|
|
|
|
func TestWizardHelpWidth_Zero(t *testing.T) {
|
|
w := wizardHelpWidth(0)
|
|
assert.Greater(t, w, 0)
|
|
}
|
|
|
|
func TestWizardHelpWidth_Narrow(t *testing.T) {
|
|
w := wizardHelpWidth(30)
|
|
assert.LessOrEqual(t, w, 30)
|
|
}
|
|
|
|
func TestWizardHelpWidth_Normal(t *testing.T) {
|
|
w := wizardHelpWidth(120)
|
|
assert.Equal(t, 70, w) // capped at 70
|
|
}
|
|
|
|
// ----- overlayContent ---------------------------------------------------
|
|
|
|
func TestOverlayContent_BasicPlacement(t *testing.T) {
|
|
bg := strings.Repeat("background\n", 20)
|
|
overlay := "MODAL"
|
|
result := overlayContent(bg, overlay, 80, 20)
|
|
assert.Contains(t, result, "MODAL")
|
|
}
|
|
|
|
// ----- buildFooterLines -------------------------------------------------
|
|
|
|
func TestBuildFooterLines_NormalWidth(t *testing.T) {
|
|
m := newTestModel()
|
|
lines := m.buildFooterLines(120)
|
|
require.NotEmpty(t, lines)
|
|
// Last line should contain the total count.
|
|
lastLine := lines[len(lines)-1]
|
|
assert.Contains(t, lastLine, "Total:")
|
|
}
|
|
|
|
func TestBuildFooterLines_NarrowTerminal(t *testing.T) {
|
|
m := newTestModel()
|
|
// Narrow terminal forces wrapping.
|
|
lines := m.buildFooterLines(40)
|
|
require.NotEmpty(t, lines)
|
|
}
|
|
|
|
// ----- getTermDimensions ------------------------------------------------
|
|
|
|
func TestGetTermDimensions_Defaults(t *testing.T) {
|
|
m := model{ui: NewBubbleTeaUI(nil, "1.0.0"), termWidth: 0, termHeight: 0}
|
|
w, h := m.getTermDimensions()
|
|
assert.Equal(t, DefaultTermWidth, w)
|
|
assert.Equal(t, DefaultTermHeight, h)
|
|
}
|
|
|
|
func TestGetTermDimensions_Set(t *testing.T) {
|
|
m := model{ui: NewBubbleTeaUI(nil, "1.0.0"), termWidth: 200, termHeight: 50}
|
|
w, h := m.getTermDimensions()
|
|
assert.Equal(t, 200, w)
|
|
assert.Equal(t, 50, h)
|
|
}
|
|
|
|
// ----- getStatusIconAndText ---------------------------------------------
|
|
|
|
func TestGetStatusIconAndText(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080}
|
|
ui.AddForward("id-1", fwd)
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
tests := []struct {
|
|
status string
|
|
expectIcon string
|
|
expectText string
|
|
disabled bool
|
|
}{
|
|
{"Active", "●", "Active", false},
|
|
{"Starting", "○", "Starting", false},
|
|
{"Reconnecting", "◐", "Reconnecting", false},
|
|
{"Error", "✗", "Error", false},
|
|
{"Active", "○", "Disabled", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.status+"_disabled="+func() string {
|
|
if tt.disabled {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}(), func(t *testing.T) {
|
|
ui.mu.Lock()
|
|
ui.forwards["id-1"].Status = tt.status
|
|
ui.disabledMap["id-1"] = tt.disabled
|
|
ui.mu.Unlock()
|
|
|
|
ui.mu.RLock()
|
|
icon, text := m.getStatusIconAndText("id-1", ui.forwards["id-1"])
|
|
ui.mu.RUnlock()
|
|
|
|
assert.Equal(t, tt.expectIcon, icon)
|
|
assert.Equal(t, tt.expectText, text)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ----- defaultMainViewColors / mainViewKeyBindings ----------------------
|
|
|
|
func TestDefaultMainViewColors(t *testing.T) {
|
|
colors := defaultMainViewColors()
|
|
assert.NotEmpty(t, string(colors.header))
|
|
assert.NotEmpty(t, string(colors.active))
|
|
}
|
|
|
|
func TestMainViewKeyBindings(t *testing.T) {
|
|
bindings := mainViewKeyBindings()
|
|
require.NotEmpty(t, bindings)
|
|
// Spot-check a few expected bindings.
|
|
var keys []string
|
|
for _, b := range bindings {
|
|
keys = append(keys, b.key)
|
|
}
|
|
assert.Contains(t, keys, "n")
|
|
assert.Contains(t, keys, "d")
|
|
}
|
|
|
|
// ----- safeRecover -------------------------------------------------------
|
|
|
|
func TestSafeRecover_DoesNotPanicOnRecover(t *testing.T) {
|
|
// Must not propagate panic.
|
|
assert.NotPanics(t, func() {
|
|
func() {
|
|
defer safeRecover("test context")
|
|
panic("test panic")
|
|
}()
|
|
})
|
|
}
|
|
|
|
// ----- BenchmarkResults (populate via handleBenchmarkComplete) -----------
|
|
|
|
func TestBenchmarkResults_FromCompleteMsg(t *testing.T) {
|
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
|
ui.mu.Lock()
|
|
ui.viewMode = ViewModeBenchmark
|
|
state := newBenchmarkState("fwd-id", "alias", 8080)
|
|
state.running = true
|
|
ui.benchmarkState = state
|
|
ui.mu.Unlock()
|
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
|
|
|
results := &benchmark.Results{
|
|
TotalRequests: 100,
|
|
Successful: 100,
|
|
Failed: 0,
|
|
}
|
|
// CalculateStats operates on the Latencies slice; with no durations, stats are zero.
|
|
_ = results.CalculateStats()
|
|
|
|
msg := BenchmarkCompleteMsg{
|
|
ForwardID: "fwd-id",
|
|
Results: results,
|
|
}
|
|
m.handleBenchmarkComplete(msg)
|
|
|
|
ui.mu.RLock()
|
|
defer ui.mu.RUnlock()
|
|
require.NotNil(t, ui.benchmarkState.results)
|
|
assert.Equal(t, 100, ui.benchmarkState.results.TotalRequests)
|
|
}
|