package tui import ( "context" "errors" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" "proxyctl/internal/probe" "proxyctl/internal/profile" ) func openProtocolProbeEditor(t *testing.T, module *recordingModule, detect func(context.Context, string) (probe.Capabilities, error), raw string) Model { t.Helper() model := resizedModel(t, module, themeModeNoColor, 80, 24) model.detect = detect model = updateKey(model, "m") model = updateKey(model, "n") model = updateRunes(model, "office") model = updateKey(model, "tab") return updateRunes(model, raw) } func startProtocolProbe(t *testing.T, model Model) (Model, tea.Cmd) { t.Helper() updated, command := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) model = updated.(Model) if command == nil { t.Fatal("Ctrl+D did not emit an explicit Protocol Probe command") } return model, command } func finishProtocolProbe(t *testing.T, model Model, command tea.Cmd) Model { t.Helper() updated, _ := model.Update(command()) return updated.(Model) } func TestCompleteModelProtocolProbeIsVisibleExplicitAndFieldAttached(t *testing.T) { calls := 0 module := &recordingModule{} model := openProtocolProbeEditor(t, module, func(_ context.Context, address string) (probe.Capabilities, error) { calls++ return probe.Capabilities{HTTP: true}, nil }, "proxy.example:8080") view := model.View() assertUsableFrame(t, view, 80, 24, "Single proxy URL", "Protocol Probe [Ctrl+D]: available", "Ctrl+D: probe") if calls != 0 { t.Fatalf("opening, focusing, or editing the form ran %d hidden probes", calls) } model, command := startProtocolProbe(t, model) view = model.View() assertUsableFrame(t, view, 80, 24, "Protocol Probe: checking proxy.example:8080", "Protocol Probe in progress") if strings.Contains(view, "Status: Protocol Probe") { t.Fatalf("probe progress rendered as unrelated global status:\n%s", view) } field := strings.Index(view, "Single proxy URL") progress := strings.Index(view, "Protocol Probe: checking") if field < 0 || progress < field || strings.Count(view[field:progress], "\n") > 1 { t.Fatalf("probe progress was not attached directly to the initiating field:\n%s", view) } if calls != 0 { t.Fatalf("accepting the shortcut ran work synchronously; calls=%d", calls) } model = finishProtocolProbe(t, model, command) view = model.View() assertUsableFrame(t, view, 80, 24, "Protocol Probe: detected HTTP proxy at proxy.example:8080") if strings.Contains(view, "Status: Protocol Probe") { t.Fatalf("probe result rendered as unrelated global status:\n%s", view) } if calls != 1 { t.Fatalf("explicit probe ran %d times, want 1", calls) } model = updateKey(model, "ctrl+s") if calls != 1 { t.Fatalf("saving ran a hidden probe; calls=%d", calls) } } func TestCompleteModelProtocolProbeNeverRunsWithoutExplicitInvocation(t *testing.T) { calls := 0 module := &recordingModule{} model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) { calls++ return probe.Capabilities{HTTP: true}, nil }, "proxy.example:8080") model = updateKey(model, "tab") model = updateKey(model, "shift+tab") model = resize(model, 120, 36) model = updateKey(model, "ctrl+s") if calls != 0 { t.Fatalf("form entry, focus changes, resize, or save ran %d hidden probes", calls) } if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile { t.Fatalf("ordinary save dispatched %#v", module.intents) } } func TestCompleteModelProtocolProbeCompletionPreservesAnotherEditedFieldsFocusCursorAndViewport(t *testing.T) { module := &recordingModule{} model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) { return probe.Capabilities{HTTP: true}, nil }, "proxy.example:8080") model, command := startProtocolProbe(t, model) model = updateKey(model, "shift+tab") model = editorKey(model, tea.KeyHome) model = updateRunes(model, "a-very-long-prefix-that-forces-the-name-field-to-scroll-horizontally-") model = editorKey(model, tea.KeyLeft) before := model.View() assertUsableFrame(t, before, 80, 24, "> Name [focused]", "Protocol Probe: checking proxy.example:8080") model = finishProtocolProbe(t, model, command) after := model.View() assertUsableFrame(t, after, 80, 24, "> Name [focused]", "Protocol Probe: detected HTTP proxy at proxy.example:8080") if fieldLine(before, "> Name [focused]") != fieldLine(after, "> Name [focused]") { t.Fatalf("non-mode-changing probe completion reset the edited field viewport or cursor:\nbefore:\n%s\nafter:\n%s", before, after) } model = updateRunes(model, "X") model = updateKey(model, "ctrl+s") if len(module.intents) != 1 { t.Fatalf("save after asynchronous completion dispatched %#v", module.intents) } if got, want := module.intents[0].Profile.Name, "a-very-long-prefix-that-forces-the-name-field-to-scroll-horizontallyX-office"; got != want { t.Fatalf("focus/cursor was not preserved: saved name = %q, want %q", got, want) } if got := module.intents[0].Profile.SingleURL; got != "http://proxy.example:8080" { t.Fatalf("probe did not update its affected URL input: %q", got) } } func fieldLine(frame, label string) string { for _, line := range strings.Split(frame, "\n") { if strings.Contains(line, label) { return line } } return "" } func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations(t *testing.T) { tests := []struct { name string caps probe.Capabilities want profile.Profile text string }{ { name: "HTTP only", caps: probe.Capabilities{HTTP: true}, want: profile.Profile{Mode: profile.SingleMode, SingleURL: "http://proxy.example:7890", HTTPURL: "http://proxy.example:7890", HTTPSURL: "http://proxy.example:7890", AllURL: "http://proxy.example:7890"}, text: "detected HTTP proxy", }, { name: "SOCKS5 only", caps: probe.Capabilities{SOCKS5: true}, want: profile.Profile{Mode: profile.SingleMode, SingleURL: "socks5h://proxy.example:7890", HTTPURL: "socks5h://proxy.example:7890", HTTPSURL: "socks5h://proxy.example:7890", AllURL: "socks5h://proxy.example:7890"}, text: "detected SOCKS5 proxy", }, { name: "HTTP and SOCKS5", caps: probe.Capabilities{HTTP: true, SOCKS5: true}, want: profile.Profile{Mode: profile.AdvancedMode, SingleURL: "proxy.example:7890", HTTPURL: "http://proxy.example:7890", HTTPSURL: "http://proxy.example:7890", AllURL: "socks5://proxy.example:7890"}, text: "detected HTTP and SOCKS5 proxy", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { module := &recordingModule{} model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) { return test.caps, nil }, "proxy.example:7890") model, command := startProtocolProbe(t, model) model = finishProtocolProbe(t, model, command) view := model.View() assertUsableFrame(t, view, 80, 24, test.text, "proxy.example:7890") if strings.Contains(view, "Status: Protocol Probe") { t.Fatalf("capability result detached into global status:\n%s", view) } attachedField := "Single proxy URL" if test.caps.HTTP && test.caps.SOCKS5 { attachedField = "Single proxy URL probe [read-only]" } field := strings.Index(view, attachedField) result := strings.Index(view, "Protocol Probe: detected") if field < 0 || result < field || strings.Count(view[field:result], "\n") > 1 { t.Fatalf("capability result was not attached to its initiating URL context:\n%s", view) } model = updateKey(model, "ctrl+s") if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile { t.Fatalf("save after probe dispatched %#v", module.intents) } got := module.intents[0].Profile if got.Mode != test.want.Mode || got.SingleURL != test.want.SingleURL || got.HTTPURL != test.want.HTTPURL || got.HTTPSURL != test.want.HTTPSURL || got.AllURL != test.want.AllURL { t.Fatalf("saved capability representation = %#v, want %#v", got, test.want) } }) } } func TestCompleteModelProtocolProbeDualResultRetainsSingleSourceAndLinksGeneratedFields(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, command := startProtocolProbe(t, model) model = finishProtocolProbe(t, model, command) view := model.View() assertUsableFrame(t, view, 80, 24, "Create Proxy Profile — advanced mode", "Single proxy URL probe [read-only]: proxy.example:7890", "Protocol Probe: detected HTTP and SOCKS5 proxy at proxy.example:7890", "Generated Advanced URL fields from this Single proxy URL probe:", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL") if strings.Index(view, "Single proxy URL probe [read-only]") > strings.Index(view, "HTTP URL") { t.Fatalf("initiating Single URL context was not visibly linked before generated fields:\n%s", view) } if strings.Contains(fieldLine(view, "HTTP URL"), "Protocol Probe") { t.Fatalf("dual result was silently relabelled as an HTTP-field probe:\n%s", view) } } func TestCompleteModelProtocolProbeFailureAndUnrecognizedResultsRemainEditableAndSavable(t *testing.T) { tests := []struct { name string caps probe.Capabilities err error text string }{ {name: "failure", err: errors.New("connection refused"), text: "Protocol Probe failed for proxy.example:8080"}, {name: "unrecognized", text: "Protocol Probe: unrecognized protocol at proxy.example:8080"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { module := &recordingModule{} model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) { return test.caps, test.err }, "proxy.example:8080") model, command := startProtocolProbe(t, model) model = finishProtocolProbe(t, model, command) assertUsableFrame(t, model.View(), 80, 24, test.text) model = editorKey(model, tea.KeyHome) model = updateRunes(model, "http://") if strings.Contains(model.View(), "Protocol Probe:") || strings.Contains(model.View(), "Protocol Probe failed") { t.Fatalf("editing the URL left stale probe feedback:\n%s", model.View()) } model = updateKey(model, "ctrl+s") if len(module.intents) != 1 || module.intents[0].Profile.SingleURL != "http://proxy.example:8080" { t.Fatalf("manual value was not savable after %s: %#v", test.name, module.intents) } }) } } func TestCompleteModelProtocolProbeSuppressesDuplicateActiveInvocation(t *testing.T) { calls := 0 model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) { calls++ return probe.Capabilities{HTTP: true}, nil }, "proxy.example:8080") model, command := startProtocolProbe(t, model) updated, duplicate := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) model = updated.(Model) if duplicate != nil { t.Fatal("repeated Ctrl+D emitted duplicate work while the same probe was active") } model = finishProtocolProbe(t, model, command) if calls != 1 { t.Fatalf("detector called %d times, want 1", calls) } } func TestCompleteModelProtocolProbeInvalidatesEveryStaleResultPath(t *testing.T) { tests := []struct { name string change func(Model) Model check func(*testing.T, Model, *recordingModule) }{ { name: "relevant URL edit", change: func(model Model) Model { return updateRunes(model, "0") }, check: func(t *testing.T, model Model, _ *recordingModule) { if !strings.Contains(model.View(), "proxy.example:80800") { t.Fatalf("URL edit was not preserved:\n%s", model.View()) } }, }, { name: "mode change", change: func(model Model) Model { return updateKey(model, "ctrl+t") }, check: func(t *testing.T, model Model, _ *recordingModule) { if !strings.Contains(model.View(), "advanced mode") { t.Fatalf("mode change was not preserved:\n%s", model.View()) } }, }, { name: "rejected save", change: func(model Model) Model { model = updateKey(model, "shift+tab") model = editorKey(model, tea.KeyCtrlU) return updateKey(model, "ctrl+s") }, check: func(t *testing.T, model Model, module *recordingModule) { if len(module.intents) != 0 || !strings.Contains(model.View(), "Profile name is required") { t.Fatalf("rejected save changed persistence or left the form: intents=%#v\n%s", module.intents, model.View()) } }, }, { name: "save", change: func(model Model) Model { return updateKey(model, "ctrl+s") }, check: func(t *testing.T, model Model, module *recordingModule) { if !strings.Contains(model.View(), "Proxy Profile management") || len(module.intents) != 1 { t.Fatalf("save did not leave the form exactly once: intents=%#v\n%s", module.intents, model.View()) } }, }, { name: "cancel", change: func(model Model) Model { return updateKey(model, "esc") }, check: func(t *testing.T, model Model, module *recordingModule) { if !strings.Contains(model.View(), "Proxy Profile management") || len(module.intents) != 0 { t.Fatalf("cancel changed persistence: intents=%#v\n%s", module.intents, model.View()) } }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { module := &recordingModule{} model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) { return probe.Capabilities{HTTP: true}, nil }, "proxy.example:8080") model, command := startProtocolProbe(t, model) model = test.change(model) model = finishProtocolProbe(t, model, command) test.check(t, model, module) view := model.View() if strings.Contains(view, "detected HTTP proxy") || strings.Contains(view, "http://proxy.example:8080") { t.Fatalf("stale result survived %s:\n%s", test.name, view) } }) } } func TestCompleteModelProtocolProbeRejectsCancelledFormResultAfterAnotherProbeStarts(t *testing.T) { model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) { return probe.Capabilities{HTTP: true}, nil }, "old.example:8080") model, oldCommand := startProtocolProbe(t, model) model = updateKey(model, "esc") model = updateKey(model, "n") model = updateRunes(model, "replacement") model = updateKey(model, "tab") model = updateRunes(model, "new.example:8080") model, newCommand := startProtocolProbe(t, model) model = finishProtocolProbe(t, model, oldCommand) view := model.View() assertUsableFrame(t, view, 80, 24, "new.example:8080", "Protocol Probe: checking new.example:8080") if strings.Contains(view, "old.example") || strings.Contains(view, "detected HTTP proxy") { t.Fatalf("cancelled form result completed a newer form's probe:\n%s", view) } model = finishProtocolProbe(t, model, newCommand) assertUsableFrame(t, model.View(), 80, 24, "Protocol Probe: detected HTTP proxy at new.example:8080") } func TestCompleteModelProtocolProbeSurvivesResizeWithoutRestartingOrDetaching(t *testing.T) { calls := 0 model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) { calls++ return probe.Capabilities{SOCKS5: true}, nil }, "proxy.example:1080") model, command := startProtocolProbe(t, model) 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, "Protocol Probe: checking proxy.example:1080") if strings.Contains(view, "Status: Protocol Probe") { t.Fatalf("resize detached probe progress at %dx%d:\n%s", size.width, size.height, view) } } if calls != 0 { t.Fatalf("resize ran %d hidden probe calls", calls) } model = finishProtocolProbe(t, model, command) assertUsableFrame(t, model.View(), 80, 24, "detected SOCKS5 proxy at proxy.example:1080") if calls != 1 { t.Fatalf("probe ran %d times across resize, want 1", calls) } } func TestCompleteModelProtocolProbeKeepsAllUserinfoOutOfDialStatusErrorsAndEveryFormFrame(t *testing.T) { const ( inputUser = "alice-private" inputSecret = "input-secret" errorUser = "mallory-private" errorSecret = "error-secret" ) var dialed []string module := &recordingModule{} model := openProtocolProbeEditor(t, module, func(_ context.Context, address string) (probe.Capabilities, error) { dialed = append(dialed, address) return probe.Capabilities{}, errors.New("failed https://" + errorUser + ":" + errorSecret + "@evil.example:9999") }, inputUser+":"+inputSecret+"@proxy.example:8080") frames := []string{model.View()} model, command := startProtocolProbe(t, model) frames = append(frames, model.View()) model = finishProtocolProbe(t, model, command) frames = append(frames, model.View()) model = resize(model, 60, 16) frames = append(frames, model.View()) model = resize(model, 80, 24) frames = append(frames, model.View()) if len(dialed) != 1 || dialed[0] != "proxy.example:8080" { t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed) } for i, frame := range frames { for _, literal := range []string{inputUser, inputSecret, errorUser, errorSecret} { if strings.Contains(frame, literal) { t.Fatalf("form frame %d exposed userinfo literal %q:\n%s", i, literal, frame) } } } assertUsableFrame(t, frames[len(frames)-1], 80, 24, "Protocol Probe failed for proxy.example:8080") model = updateKey(model, "ctrl+s") if len(module.intents) != 1 || module.intents[0].Profile.SingleURL != "http://"+inputUser+":"+inputSecret+"@proxy.example:8080" { t.Fatalf("userinfo masking weakened saved Profile semantics: %#v", module.intents) } }