414 lines
18 KiB
Go
414 lines
18 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/probe"
|
|
"proxyctl/internal/profile"
|
|
)
|
|
|
|
func TestCompleteModelContextualHelpOpensOverEveryPageAndRestoresPageState(t *testing.T) {
|
|
module := &recordingModule{snapshot: app.Snapshot{
|
|
ActiveProfile: "office",
|
|
Profiles: []profile.Profile{
|
|
{Name: "office", Mode: profile.SingleMode, SingleURL: "http://office.example:8080"},
|
|
{Name: "remote", Mode: profile.SingleMode, SingleURL: "http://remote.example:8080"},
|
|
},
|
|
Targets: operationSnapshot().Targets,
|
|
}}
|
|
|
|
t.Run("dashboard focus and Target selection", func(t *testing.T) {
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
model = updateKey(model, "down")
|
|
model = updateKey(model, "tab")
|
|
model = updateKey(model, "down")
|
|
model = updateKey(model, "?")
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 120, 36, "Operations Console", "Contextual Help", "Dashboard", "Target details focus", "APT", "Disable")
|
|
|
|
model = updateKey(model, " ")
|
|
if len(module.intents) != 0 {
|
|
t.Fatalf("input in Help acted on the dashboard: %#v", module.intents)
|
|
}
|
|
model = updateKey(model, "esc")
|
|
if model.page != mainPage || model.selectedTargetID != "apt" || model.dashboardFocus != targetDetailsPane || model.detailsOffset != 1 {
|
|
t.Fatalf("closing Help did not restore dashboard state: page=%d target=%q focus=%d offset=%d", model.page, model.selectedTargetID, model.dashboardFocus, model.detailsOffset)
|
|
}
|
|
})
|
|
|
|
t.Run("Profile management selection and viewport", func(t *testing.T) {
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
model = updateKey(model, "down")
|
|
model = updateKey(model, "pgdown")
|
|
model = updateKey(model, "?")
|
|
assertUsableFrame(t, model.View(), 80, 24, "Proxy Profile management", "Contextual Help", "Profile management", "remote", "Edit selected Profile")
|
|
model = updateKey(model, "q")
|
|
if model.page != profilesPage || model.selectedProfileName != "remote" || model.profileDetailsOffset != 5 {
|
|
t.Fatalf("closing Help did not restore management state: page=%d Profile=%q offset=%d", model.page, model.selectedProfileName, model.profileDetailsOffset)
|
|
}
|
|
})
|
|
|
|
t.Run("Profile form field focus", func(t *testing.T) {
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
model = updateKey(model, "e")
|
|
model = updateKey(model, "tab")
|
|
model = updateKey(model, "?")
|
|
assertUsableFrame(t, model.View(), 80, 24, "Edit Proxy Profile", "Contextual Help", "Profile form", "Single proxy URL field")
|
|
model = updateKey(model, "esc")
|
|
if model.page != formPage || model.form.field != 1 || model.form.profile.Name != "office" {
|
|
t.Fatalf("closing Help did not restore form state: page=%d field=%d Profile=%q", model.page, model.form.field, model.form.profile.Name)
|
|
}
|
|
})
|
|
|
|
t.Run("Active Profile picker search and selection", func(t *testing.T) {
|
|
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 80, 24))
|
|
model = updateKey(model, "down")
|
|
model = updateKey(model, "r")
|
|
model = updateKey(model, "?")
|
|
assertUsableFrame(t, model.View(), 80, 24, "Contextual Help", "Active Profile picker", "q is ordinary search input", "Ctrl+P/Ctrl+N")
|
|
model = updateKey(model, "esc")
|
|
assertUsableFrame(t, model.View(), 80, 24, "Switch Active Profile", "Search: r", "> remote")
|
|
})
|
|
}
|
|
|
|
func TestCompleteModelLongHelpScrollsAndSurvivesResize(t *testing.T) {
|
|
model := resizedModel(t, responsiveLayoutModule(), themeModeNoColor, 60, 16)
|
|
model = updateKey(model, "?")
|
|
initial := model.View()
|
|
assertUsableFrame(t, initial, 60, 16, "Contextual Help", "Dashboard")
|
|
if strings.Contains(initial, "Global exit safety") {
|
|
t.Fatalf("long Help unexpectedly exposed its final section before scrolling:\n%s", initial)
|
|
}
|
|
|
|
for range 6 {
|
|
model = updateKey(model, "pgdown")
|
|
}
|
|
scrolled := model.View()
|
|
assertUsableFrame(t, scrolled, 60, 16, "Global exit safety", "Ctrl+C")
|
|
if scrolled == initial {
|
|
t.Fatal("PgDn did not scroll Help")
|
|
}
|
|
|
|
model = resize(model, 120, 36)
|
|
model = resize(model, 60, 16)
|
|
assertUsableFrame(t, model.View(), 60, 16, "Global exit safety")
|
|
model = updateKey(model, "home")
|
|
assertUsableFrame(t, model.View(), 60, 16, "Dashboard")
|
|
}
|
|
|
|
func TestCompleteModelHelpStacksOverConfirmationAndBlocksUnderlyingInput(t *testing.T) {
|
|
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{Name: "office"}, {Name: "remote"}}}}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
model = updateKey(model, "d")
|
|
model = updateKey(model, "?")
|
|
assertUsableFrame(t, model.View(), 80, 24, "Contextual Help", "Delete confirmation", "Deletion cannot be undone")
|
|
|
|
model = updateKey(model, "y")
|
|
if len(module.intents) != 0 || !strings.Contains(model.View(), "Contextual Help") {
|
|
t.Fatalf("confirmation key leaked through Help: intents=%#v\n%s", module.intents, model.View())
|
|
}
|
|
model = updateKey(model, "esc")
|
|
assertUsableFrame(t, model.View(), 80, 24, "Delete Proxy Profile “office”?", "> Cancel")
|
|
model = updateKey(model, "esc")
|
|
if len(module.intents) != 0 || strings.Contains(model.View(), "Delete Proxy Profile") {
|
|
t.Fatalf("Escape did not close dialogs from the top without mutation: intents=%#v\n%s", module.intents, model.View())
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelDialogResizeAndHelpPreserveMeaningfulFocus(t *testing.T) {
|
|
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{Name: "office"}, {Name: "remote"}}}}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
model = updateKey(model, "d")
|
|
model = updateKey(model, "right")
|
|
model = resize(model, 60, 16)
|
|
model = updateKey(model, "?")
|
|
assertUsableFrame(t, model.View(), 60, 16, "Delete confirmation", "Delete is focused")
|
|
model = resize(model, 120, 36)
|
|
model = updateKey(model, "esc")
|
|
assertUsableFrame(t, model.View(), 120, 36, "> Delete")
|
|
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
model = updated.(Model)
|
|
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile {
|
|
t.Fatalf("resize and Help changed confirmation focus: intents=%#v", module.intents)
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelNewConfirmationTakesPrecedenceOverOpenHelp(t *testing.T) {
|
|
updates := make(chan app.OperationUpdate, 1)
|
|
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
model = updateKey(model, "?")
|
|
|
|
updates <- app.ConfirmationOperationUpdate(module.snapshot, app.Confirmation{Title: "Confirm Docker restart", Summary: "Running work may be disrupted."})
|
|
close(updates)
|
|
model, _ = applyOperationCommand(t, model, command)
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 120, 36, "Confirm Docker restart", "> Cancel")
|
|
if strings.Contains(view, "Contextual Help") {
|
|
t.Fatalf("new safety confirmation did not take precedence over older Help:\n%s", view)
|
|
}
|
|
|
|
model = updateKey(model, "esc")
|
|
assertUsableFrame(t, model.View(), 120, 36, "Contextual Help", "No mutation is in progress")
|
|
model = updateKey(model, "esc")
|
|
if strings.Contains(model.View(), "Contextual Help") {
|
|
t.Fatal("second Escape did not close the revealed Help dialog")
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelGlobalBackQuitAndInterruptSemantics(t *testing.T) {
|
|
module := &recordingModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}}
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
model = updateKey(model, "e")
|
|
model = updateKey(model, "q")
|
|
if model.page != profilesPage {
|
|
t.Fatalf("q from form page = %d, want profilesPage", model.page)
|
|
}
|
|
model = updateKey(model, "q")
|
|
if model.page != mainPage {
|
|
t.Fatalf("q from management page = %d, want mainPage", model.page)
|
|
}
|
|
updated, quit := model.Update(keyMessage("q"))
|
|
model = updated.(Model)
|
|
if quit == nil {
|
|
t.Fatal("q from unobscured dashboard did not request exit")
|
|
}
|
|
if _, ok := quit().(tea.QuitMsg); !ok {
|
|
t.Fatal("q from dashboard did not emit tea.Quit")
|
|
}
|
|
|
|
for _, setup := range []func(Model) Model{
|
|
func(model Model) Model { return model },
|
|
func(model Model) Model { return updateKey(model, "m") },
|
|
func(model Model) Model { return updateKey(updateKey(model, "m"), "e") },
|
|
func(model Model) Model { return updateKey(model, "?") },
|
|
func(model Model) Model { return openProfilePicker(t, model) },
|
|
func(model Model) Model { return updateKey(updateKey(model, "m"), "d") },
|
|
} {
|
|
candidate := setup(resizedModel(t, module, themeModeNoColor, 80, 24))
|
|
_, command := candidate.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
|
if command == nil {
|
|
t.Fatalf("Ctrl+C from page or dialog did not request exit:\n%s", candidate.View())
|
|
}
|
|
if _, ok := command().(tea.QuitMsg); !ok {
|
|
t.Fatalf("Ctrl+C from page or dialog did not emit tea.Quit:\n%s", candidate.View())
|
|
}
|
|
}
|
|
|
|
driftSnapshot := operationSnapshot()
|
|
driftSnapshot.Targets[0].PrimaryAction = app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}
|
|
driftSnapshot.Targets[0].DriftResolutionActions = []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}}
|
|
driftModule := &recordingModule{snapshot: driftSnapshot}
|
|
drift := resizedModel(t, driftModule, themeModeNoColor, 80, 24)
|
|
drift = updateKey(drift, " ")
|
|
_, command := drift.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
|
if command == nil {
|
|
t.Fatal("Ctrl+C from Drift Resolution did not request exit")
|
|
}
|
|
if _, ok := command().(tea.QuitMsg); !ok {
|
|
t.Fatal("Ctrl+C from Drift Resolution did not emit tea.Quit")
|
|
}
|
|
}
|
|
|
|
func TestCompleteModelHelpDuringMutationExplainsAndPreservesDisabledActions(t *testing.T) {
|
|
updates := make(chan app.OperationUpdate, 1)
|
|
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
|
model := resizedModel(t, module, themeModeNoColor, 60, 16)
|
|
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
model = updateKey(model, "?")
|
|
for range 3 {
|
|
model = updateKey(model, "pgdown")
|
|
}
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 60, 16, "Mutation in progress", "Conflicting mutations are disabled", "Ctrl+C: defer safe exit")
|
|
if strings.Contains(view, "Ctrl+C: exit") {
|
|
t.Fatalf("busy Help incorrectly claimed Ctrl+C exits immediately:\n%s", view)
|
|
}
|
|
|
|
model = updateKey(model, "esc")
|
|
updated, blocked := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
if blocked != nil || len(module.intents) != 1 || !strings.Contains(model.View(), "Wait for the current mutation to finish") {
|
|
t.Fatalf("Help browsing changed mutation serialization: intents=%#v\n%s", module.intents, model.View())
|
|
}
|
|
close(updates)
|
|
_ = command
|
|
}
|
|
|
|
func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) {
|
|
module := &recordingModule{snapshot: app.Snapshot{
|
|
ActiveProfile: "office",
|
|
Profiles: []profile.Profile{{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080"}},
|
|
Targets: operationSnapshot().Targets,
|
|
}}
|
|
|
|
t.Run("dashboard and busy details", func(t *testing.T) {
|
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
|
assertUsableFrame(t, model.View(), 120, 36, "Space: Enable", "u: show unavailable", "r: refresh", "q/Ctrl+C: quit")
|
|
model = updateKey(model, "tab")
|
|
assertUsableFrame(t, model.View(), 120, 36, "PgUp/PgDn: scroll", "u: show unavailable", "q/Ctrl+C: quit")
|
|
|
|
updates := make(chan app.OperationUpdate)
|
|
busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
|
model = resizedModel(t, busyModule, themeModeNoColor, 120, 36)
|
|
model = updateKey(model, "tab")
|
|
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
model = updated.(Model)
|
|
assertUsableFrame(t, model.View(), 120, 36, "u: show unavailable", "q/Ctrl+C: defer safe exit", "mutations disabled")
|
|
model = updateKey(model, "u")
|
|
assertUsableFrame(t, model.View(), 120, 36, "u: hide unavailable")
|
|
close(updates)
|
|
})
|
|
|
|
t.Run("picker, management, and confirmation", func(t *testing.T) {
|
|
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 80, 24))
|
|
assertUsableFrame(t, model.View(), 80, 24, "Type to search", "PgUp/PgDn: select", "Enter: activate", "Esc: cancel", "?: help")
|
|
model = updateKey(model, "esc")
|
|
model = updateKey(model, "m")
|
|
assertUsableFrame(t, model.View(), 80, 24, "n: create", "e: edit", "d: delete", "q/Esc: back")
|
|
model = updateKey(model, "d")
|
|
assertUsableFrame(t, model.View(), 80, 24, "←/→/Tab: choose", "Enter: choose", "y: delete", "n/q/Esc: cancel", "?: help")
|
|
model = updateKey(model, "q")
|
|
if model.profileDelete != nil || model.page != profilesPage {
|
|
t.Fatal("confirmation footer advertised q cancellation but q did not close only the dialog")
|
|
}
|
|
})
|
|
|
|
t.Run("form probe hint follows mode, focus, and progress", func(t *testing.T) {
|
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
model = updateKey(model, "m")
|
|
model = updateKey(model, "e")
|
|
view := model.View()
|
|
assertUsableFrame(t, view, 80, 24, "Shift+Tab", "Ctrl+T: mode", "Ctrl+S: save", "q/Esc: cancel")
|
|
if strings.Contains(view, "Ctrl+D: probe") {
|
|
t.Fatalf("form advertised a field action while Name was focused:\n%s", view)
|
|
}
|
|
|
|
model = updateKey(model, "tab")
|
|
assertUsableFrame(t, model.View(), 80, 24, "Ctrl+D: probe")
|
|
updated, probeCommand := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
|
|
model = updated.(Model)
|
|
if probeCommand == nil || !strings.Contains(model.View(), "Protocol Probe in progress") || strings.Contains(model.View(), "Ctrl+D: probe") {
|
|
t.Fatalf("probe footer did not follow accepted in-field action and progress:\n%s", model.View())
|
|
}
|
|
model = updateKey(model, "ctrl+t")
|
|
if strings.Contains(model.View(), "Ctrl+D: probe") || !strings.Contains(model.View(), "advanced mode") {
|
|
t.Fatalf("Advanced form advertised a Single-field Protocol Probe:\n%s", model.View())
|
|
}
|
|
})
|
|
|
|
t.Run("Help exit hint follows operation state", func(t *testing.T) {
|
|
idle := resizedModel(t, module, themeModeNoColor, 80, 24)
|
|
idle = updateKey(idle, "?")
|
|
assertUsableFrame(t, idle.View(), 80, 24, "Ctrl+C: exit")
|
|
|
|
updates := make(chan app.OperationUpdate)
|
|
busyModule := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
|
busy := resizedModel(t, busyModule, themeModeNoColor, 80, 24)
|
|
updated, _ := busy.Update(tea.KeyMsg{Type: tea.KeySpace})
|
|
busy = updateKey(updated.(Model), "?")
|
|
assertUsableFrame(t, busy.View(), 80, 24, "Ctrl+C: defer safe exit")
|
|
updated, quit := busy.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
|
busy = updated.(Model)
|
|
if quit != nil {
|
|
t.Fatal("busy Help Ctrl+C hint promised deferral but returned immediate quit")
|
|
}
|
|
assertUsableFrame(t, busy.View(), 80, 24, "Exit pending until safe completion")
|
|
close(updates)
|
|
})
|
|
}
|
|
|
|
func TestCompleteModelHelpDocumentsProbeShortcutExplicitOnlyAttachmentAndDuplicateSuppression(t *testing.T) {
|
|
model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) {
|
|
return probe.Capabilities{HTTP: true, SOCKS5: true}, nil
|
|
}, "proxy.example:7890")
|
|
|
|
model = updateKey(model, "?")
|
|
assertUsableFrame(t, model.View(), 80, 24,
|
|
"Ctrl+D explicitly runs Protocol Probe for the focused Single proxy URL field.",
|
|
"never runs on form entry, focus changes, or save.")
|
|
model = updateKey(model, "esc")
|
|
|
|
model, command := startProtocolProbe(t, model)
|
|
updated, duplicate := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
|
|
model = updated.(Model)
|
|
if duplicate != nil {
|
|
t.Fatal("repeated Probe shortcut emitted duplicate work")
|
|
}
|
|
model = updateKey(model, "?")
|
|
assertUsableFrame(t, model.View(), 80, 24,
|
|
"Protocol Probe is in progress for the Single proxy URL field.",
|
|
"Ctrl+D does not start a duplicate probe")
|
|
model = updateKey(model, "esc")
|
|
|
|
model = finishProtocolProbe(t, model, command)
|
|
model = updateKey(model, "?")
|
|
assertUsableFrame(t, model.View(), 80, 24,
|
|
"The Protocol Probe result remains attached to its read-only initiating Single",
|
|
"proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from",
|
|
"that probe.")
|
|
}
|
|
|
|
func TestCompleteModelHelpOverEditorPreservesFieldCursorPreviewAndResizeState(t *testing.T) {
|
|
module := &profileManagementModule{}
|
|
model := openNewProfileEditor(t, module)
|
|
model = updateRunes(model, "office")
|
|
model = updateKey(model, "tab")
|
|
model = updateRunes(model, "http://proxy.example:8080")
|
|
model = updateKey(model, "tab")
|
|
items := make([]string, 24)
|
|
for i := range items {
|
|
items[i] = fmt.Sprintf("host-%02d.example", i)
|
|
}
|
|
model = updateRunes(model, strings.Join(items, ","))
|
|
for range 7 {
|
|
model = editorKey(model, tea.KeyLeft)
|
|
}
|
|
for range 3 {
|
|
model = updateKey(model, "pgdown")
|
|
}
|
|
|
|
field := model.form.field
|
|
cursor := model.form.inputs[field].input.Position()
|
|
previewLine := model.form.bypassPreviewLine
|
|
values := make([]string, len(model.form.inputs))
|
|
for i := range model.form.inputs {
|
|
values[i] = model.form.inputs[i].input.Value()
|
|
}
|
|
if previewLine == 0 {
|
|
t.Fatal("fixture did not scroll the Bypass preview")
|
|
}
|
|
|
|
model = updateKey(model, "?")
|
|
model = updateKey(model, "pgdown")
|
|
model = resize(model, 60, 16)
|
|
model = resize(model, 120, 36)
|
|
model = resize(model, 80, 24)
|
|
model = updateKey(model, "esc")
|
|
|
|
if model.page != formPage || model.form.field != field || model.form.inputs[field].input.Position() != cursor || model.form.bypassPreviewLine != previewLine {
|
|
t.Fatalf("Help or resize changed editor state: page=%d field=%d cursor=%d preview=%d", model.page, model.form.field, model.form.inputs[field].input.Position(), model.form.bypassPreviewLine)
|
|
}
|
|
for i := range values {
|
|
if got := model.form.inputs[i].input.Value(); got != values[i] {
|
|
t.Fatalf("Help or resize changed field %d: got %q want %q", i, got, values[i])
|
|
}
|
|
}
|
|
assertUsableFrame(t, model.View(), 80, 24, "> Bypass [focused]", "PgUp/PgDn: scroll form preview")
|
|
}
|