306 lines
12 KiB
Go
306 lines
12 KiB
Go
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 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, "p")
|
||
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:", "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, "p")
|
||
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 !strings.Contains(frame, redacted) {
|
||
t.Fatalf("frame %d did not apply the presentation redaction path:\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: 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, "p")
|
||
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",
|
||
"a/Enter: activate",
|
||
"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)},
|
||
{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)},
|
||
{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)},
|
||
{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)},
|
||
{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)},
|
||
},
|
||
}
|
||
}
|
||
|
||
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"},
|
||
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT summary", Details: "APT details"}, Available: true, State: target.External, Evidence: "APT evidence"},
|
||
},
|
||
}}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|