Files
proxy/internal/tui/foundation_test.go
T

345 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"bytes"
"errors"
"fmt"
"regexp"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) {
module := responsiveLayoutModule()
t.Run("wide 120x36", func(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, 120, 36)
view := model.View()
if !sameLine(view, "Targets", "Target details") {
t.Fatalf("wide layout did not place list and details side by side:\n%s", view)
}
assertUsableFrame(t, view, 120, 36, "Shell", "Shell summary", "Active Profile: office")
})
t.Run("narrow 80x24", func(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, 80, 24)
view := model.View()
if sameLine(view, "Targets", "Target details") || strings.Index(view, "Target details") < strings.Index(view, "Shell") {
t.Fatalf("narrow layout did not stack list above details:\n%s", view)
}
assertUsableFrame(t, view, 80, 24, "Shell", "Shell summary", "Active Profile: office")
})
t.Run("below minimum 59x15", func(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, 59, 15)
view := model.View()
assertUsableFrame(t, view, 59, 15, "Terminal too small", "Minimum size: 60×16", "Current size: 59×15")
if strings.Contains(view, "Shell summary") || strings.Contains(view, "Targets\n") {
t.Fatalf("minimum-size message was mixed with the application interface:\n%s", view)
}
})
}
func TestCompleteModelWideLayoutUsesOriginalTerminalBoundary(t *testing.T) {
module := responsiveLayoutModule()
for _, test := range []struct {
name string
width int
height int
wantWide bool
expandError bool
}{
{name: "one row below height boundary", width: 100, height: 27},
{name: "exact boundary", width: 100, height: 28, wantWide: true},
{name: "expanded error does not change exact boundary", width: 100, height: 28, wantWide: true, expandError: true},
} {
t.Run(test.name, func(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, test.width, test.height)
if test.expandError {
model.operation.currentError = &operationError{
status: app.OperationStatus{Kind: app.StatusError, Summary: "Target update failed"},
detail: strings.Repeat("expanded diagnostic line\n", 20),
}
model.operation.errorExpanded = true
}
view := model.View()
if got := sameLine(view, "Targets", "Target details"); got != test.wantWide {
t.Fatalf("side-by-side = %v, want %v at %dx%d (expanded=%v):\n%s", got, test.wantWide, test.width, test.height, test.expandError, view)
}
})
}
}
func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
module := responsiveLayoutModule()
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model = updateKey(model, "down")
model = resize(model, 80, 24)
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") {
t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view)
}
model = resize(model, 120, 36)
if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") {
t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view)
}
model = updateKey(model, "m")
model = updateKey(model, "e")
model = updateKey(model, "ctrl+t")
for range 4 {
model = updateKey(model, "tab")
}
for _, size := range []struct{ width, height int }{{60, 16}, {120, 36}, {80, 24}} {
model = resize(model, size.width, size.height)
view := model.View()
assertUsableFrame(t, view, size.width, size.height, "> Bypass [focused]:", "Ctrl+S: save")
}
}
func TestCompleteModelSemanticThemeSupportsDarkLightAndNoColor(t *testing.T) {
module := responsiveLayoutModule()
dark := resizedModel(t, module, themeModeDark, 80, 24).View()
light := resizedModel(t, module, themeModeLight, 80, 24).View()
plain := resizedModel(t, module, themeModeNoColor, 80, 24).View()
for name, view := range map[string]string{"dark": dark, "light": light, "NO_COLOR": plain} {
for _, semantic := range []string{"Active Profile: office", "Disabled", "External", ">"} {
if !strings.Contains(view, semantic) {
t.Fatalf("%s theme lost non-color semantic %q:\n%s", name, semantic, view)
}
}
}
if !strings.Contains(dark, "\x1b[") || !strings.Contains(light, "\x1b[") {
t.Fatalf("light or dark semantic theme emitted no styling\ndark: %q\nlight: %q", dark, light)
}
if dark == light {
t.Fatal("light and dark themes rendered identically")
}
if ansiPattern.MatchString(plain) {
t.Fatalf("NO_COLOR rendering contained ANSI styling: %q", plain)
}
}
func TestNewModelUsesInjectedBubbleTeaOutputForTerminalCapabilities(t *testing.T) {
var bubbleTeaOutput bytes.Buffer
model := resize(NewModel(responsiveLayoutModule(), WithOutput(&bubbleTeaOutput)), 80, 24)
view := model.View()
if ansiPattern.MatchString(view) {
t.Fatalf("non-terminal Bubble Tea output received ANSI styling: %q", view)
}
for _, semantic := range []string{"Active Profile: office", "Disabled", "External", ">"} {
if !strings.Contains(view, semantic) {
t.Fatalf("injected-output rendering lost semantic %q:\n%s", semantic, view)
}
}
}
func TestCompleteModelRespectsNoColorEnvironment(t *testing.T) {
t.Setenv("NO_COLOR", "1")
model := resize(NewModel(responsiveLayoutModule()), 80, 24)
view := model.View()
if ansiPattern.MatchString(view) {
t.Fatalf("NO_COLOR environment produced ANSI styling: %q", view)
}
for _, semantic := range []string{"Disabled", "External", ">"} {
if !strings.Contains(view, semantic) {
t.Fatalf("NO_COLOR environment lost semantic %q:\n%s", semantic, view)
}
}
}
func TestCompleteModelDefensivelyRedactsEveryRenderedPage(t *testing.T) {
const (
secret = "foundation-secret"
credential = "https://alice:" + secret + "@proxy.example:8443"
redacted = "https://alice:REDACTED@proxy.example:8443"
)
module := &recordingModule{snapshot: app.Snapshot{
ActiveProfile: credential,
Profiles: []profile.Profile{{
Name: credential, Mode: profile.SingleMode, SingleURL: credential, Bypass: []string{credential},
}},
Targets: []app.TargetSnapshot{{
ID: credential,
Description: target.Description{Name: credential, Summary: credential, Details: credential},
Available: true, State: target.External, Evidence: credential, AvailabilityEvidence: credential,
}},
}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
frames := []string{model.View()}
model = updateKey(model, "?")
frames = append(frames, model.View())
model = updateKey(model, "q")
model = updateKey(model, "m")
frames = append(frames, model.View())
model = updateKey(model, "e")
frames = append(frames, model.View())
model.status = "operation failed for " + credential
frames = append(frames, model.View())
for i, frame := range frames {
if strings.Contains(frame, secret) {
t.Fatalf("frame %d exposed a credential:\n%s", i, frame)
}
if i < 3 {
if !strings.Contains(frame, redacted) {
t.Fatalf("frame %d did not apply the presentation redaction path:\n%s", i, frame)
}
continue
}
if strings.Contains(frame, "alice") || !strings.Contains(frame, "REDACTED@proxy.example:8443") {
t.Fatalf("form frame %d did not mask the entire userinfo:\n%s", i, frame)
}
}
}
func TestCompleteModelRedactsApplicationErrorsBeforeRendering(t *testing.T) {
const secret = "result-secret"
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("failed https://bob:" + secret + "@proxy.example:8080")}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
updated, cmd := model.Update(keyMessage("r"))
model = updated.(Model)
updated, _ = model.Update(cmd())
model = updated.(Model)
model = updateKey(model, "e")
view := model.View()
if strings.Contains(view, secret) || !strings.Contains(view, "https://bob:REDACTED@proxy.example:8080") {
t.Fatalf("Application error bypassed presentation redaction:\n%s", view)
}
}
func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisible(t *testing.T) {
snapshot := productionScaleSnapshot()
longNotice := "Refresh completed; selected Target remains actionable. " + strings.Repeat("Additional redacted diagnostic context. ", 12)
module := &targetModule{snapshot: snapshot, result: app.Result{Snapshot: snapshot, Notice: longNotice}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
for range len(snapshot.Targets) - 1 {
model = updateKey(model, "down")
}
updated, refresh := model.Update(keyMessage("r"))
model = updated.(Model)
updated, _ = model.Update(refresh())
model = updated.(Model)
assertUsableFrame(t, model.View(), 80, 24,
"> ! Drifted Docker daemon",
"State: ! Drifted",
"Docker daemon selected evidence.",
"Status: Refresh completed; selected Target remains actionable.",
"Space: Resolve",
"p: switch Active Profile",
"m: manage Proxy Profiles",
)
model = resize(model, 120, 36)
model = resize(model, 80, 24)
assertUsableFrame(t, model.View(), 80, 24,
"> ! Drifted Docker daemon",
"State: ! Drifted",
"Docker daemon selected evidence.",
"Space: Resolve",
)
}
func TestCompleteModelLongProfileListKeepsLastSelectionDetailsAndActionsVisibleAcrossResize(t *testing.T) {
profiles := make([]profile.Profile, 32)
for i := range profiles {
profiles[i] = profile.Profile{
Name: fmt.Sprintf("regional-office-%02d", i),
Mode: profile.SingleMode,
SingleURL: fmt.Sprintf("http://proxy-%02d.example:8080", i),
}
}
module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: profiles[0].Name, Profiles: profiles}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "m")
for range len(profiles) - 1 {
model = updateKey(model, "down")
}
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {80, 24}} {
model = resize(model, size.width, size.height)
assertUsableFrame(t, model.View(), size.width, size.height,
"> regional-office-31",
"Proxy: http://proxy-31.example:8080",
"n: create",
"d: delete",
"q/Esc: back",
)
}
}
func productionScaleSnapshot() app.Snapshot {
return app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080"}},
Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: strings.Repeat("Shell detail. ", 30)}, Available: true, State: target.Enabled, Evidence: strings.Repeat("Shell evidence. ", 20), PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT summary", Details: strings.Repeat("APT detail. ", 30)}, Available: true, State: target.Disabled, Evidence: strings.Repeat("APT evidence. ", 20), PrimaryAction: app.TargetAction{Kind: app.EnableTargetAction, Available: true}},
{ID: "docker-containers", Description: target.Description{Name: "Docker containers/builds", Summary: "Docker client summary", Details: strings.Repeat("Docker client detail. ", 30)}, Available: true, State: target.External, Evidence: strings.Repeat("Docker client evidence. ", 20), PrimaryAction: app.TargetAction{Kind: app.ApplyProfileTargetAction, Available: true}},
{ID: "gnome", Description: target.Description{Name: "GNOME", Summary: "GNOME summary", Details: strings.Repeat("GNOME detail. ", 30)}, Available: true, State: target.Enabled, Evidence: strings.Repeat("GNOME evidence. ", 20), PrimaryAction: app.TargetAction{Kind: app.DisableTargetAction, Available: true}},
{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon", Summary: "Docker daemon selected summary", Details: strings.Repeat("Docker daemon selected detail. ", 30)}, Available: true, State: target.Drifted, Evidence: strings.Repeat("Docker daemon selected evidence. ", 20), PrimaryAction: app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}, DriftResolutionActions: []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}, {Kind: app.RestoreOriginalAction, Available: true}}},
},
}
}
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
func responsiveLayoutModule() *recordingModule {
return &recordingModule{snapshot: app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080"}},
Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: "Shell details"}, Available: true, State: target.Disabled, Evidence: "Shell evidence", PrimaryAction: app.TargetAction{Kind: app.EnableTargetAction, Available: true}},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT summary", Details: "APT details"}, Available: true, State: target.External, Evidence: "APT evidence", PrimaryAction: app.TargetAction{Kind: app.ApplyProfileTargetAction, Available: true}},
},
}}
}
func resizedModel(t *testing.T, module app.InteractiveModule, mode themeMode, width, height int) Model {
t.Helper()
return resize(NewModel(module, withTheme(mode)), width, height)
}
func resize(model Model, width, height int) Model {
updated, _ := model.Update(tea.WindowSizeMsg{Width: width, Height: height})
return updated.(Model)
}
func sameLine(view, first, second string) bool {
for _, line := range strings.Split(view, "\n") {
if strings.Contains(line, first) && strings.Contains(line, second) {
return true
}
}
return false
}
func assertUsableFrame(t *testing.T, view string, width, height int, semantics ...string) {
t.Helper()
lines := strings.Split(strings.TrimSuffix(view, "\n"), "\n")
if len(lines) > height {
t.Fatalf("frame has %d lines, terminal height is %d:\n%s", len(lines), height, view)
}
for _, line := range lines {
if len([]rune(line)) > width {
t.Fatalf("frame line is wider than %d columns: %q", width, line)
}
}
for _, semantic := range semantics {
if !strings.Contains(view, semantic) {
t.Fatalf("frame missing %q:\n%s", semantic, view)
}
}
}