pi-agent: Implement field Protocol Probe

This commit is contained in:
proxyctl coordinator
2026-07-18 00:27:12 +08:00
parent 21dd67cf54
commit 02b49d5112
8 changed files with 482 additions and 67 deletions
@@ -4,16 +4,16 @@
**Blocked by:** 08 — Deliver the mature Profile editor.
**Status:** ready-for-agent
**Status:** done
- [ ] The applicable URL field visibly offers Protocol Probe and documents its keyboard shortcut.
- [ ] Probe never runs when the form opens, when focus changes, or when a Profile is saved.
- [ ] Progress, endpoint-safe status, and detected capabilities render with the initiating field rather than as unrelated global status.
- [ ] HTTP-only detection populates the established HTTP representation.
- [ ] SOCKS5-only detection preserves the established `socks5h` semantics.
- [ ] Dual HTTP/SOCKS5 detection creates the established compatible Advanced Profile values.
- [ ] Editing the relevant URL, changing mode, saving, or cancelling invalidates an in-flight result.
- [ ] Repeated invocation while the same probe is active does not start duplicate work.
- [ ] Userinfo and credentials never appear in dial evidence, status, errors, or rendered frames.
- [ ] Failure and unrecognized-protocol results leave user input editable and do not prevent saving a manually chosen value.
- [ ] Complete-Model tests cover all capabilities, failure, explicit invocation, no hidden network work, duplicate suppression, resize, and every stale-result path.
- [x] The applicable URL field visibly offers Protocol Probe and documents its keyboard shortcut.
- [x] Probe never runs when the form opens, when focus changes, or when a Profile is saved.
- [x] Progress, endpoint-safe status, and detected capabilities render with the initiating field rather than as unrelated global status.
- [x] HTTP-only detection populates the established HTTP representation.
- [x] SOCKS5-only detection preserves the established `socks5h` semantics.
- [x] Dual HTTP/SOCKS5 detection creates the established compatible Advanced Profile values.
- [x] Editing the relevant URL, changing mode, saving, or cancelling invalidates an in-flight result.
- [x] Repeated invocation while the same probe is active does not start duplicate work.
- [x] Userinfo and credentials never appear in dial evidence, status, errors, or rendered frames.
- [x] Failure and unrecognized-protocol results leave user input editable and do not prevent saving a manually chosen value.
- [x] Complete-Model tests cover all capabilities, failure, explicit invocation, no hidden network work, duplicate suppression, resize, and every stale-result path.
+1 -1
View File
@@ -48,7 +48,7 @@ Start the Target panel with no arguments:
proxyctl
```
Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The editor supports cursor movement, Home, End, Delete, Backspace, insertion, paste, and horizontal scrolling; Tab or the arrow keys move between fields, and Escape cancels without saving. Validation stays beside each field. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values without discarding values entered in either mode. A bare `host:port` is accepted and normalized to `http://`. With the cursor in the Single proxy URL field, Ctrl+D runs the Protocol Probe, which detects whether the endpoint speaks HTTP or SOCKS5 and fills in the scheme. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts comma- or newline-delimited hostnames, domain suffixes, IPv4, IPv6, and CIDR entries; the editor previews normalized, deduplicated values and identifies invalid items before save.
Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The editor supports cursor movement, Home, End, Delete, Backspace, insertion, paste, and horizontal scrolling; Tab or the arrow keys move between fields, and Escape cancels without saving. Validation stays beside each field. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values without discarding values entered in either mode. A bare `host:port` is accepted and normalized to `http://`. The Single proxy URL field visibly offers Protocol Probe with Ctrl+D; it runs only on that explicit keypress, and its credential-safe progress and HTTP/SOCKS5 result stay with the affected URL field rather than global status. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts comma- or newline-delimited hostnames, domain suffixes, IPv4, IPv6, and CIDR entries; the editor previews normalized, deduplicated values and identifies invalid items before save.
The dashboard shows the Active Profile. Press `p` to open the searchable Active Profile picker, type to filter, use arrows to select, and press Enter to activate; Escape cancels without changing the Active Profile. Use arrows or `j`/`k` to select an Available Target and Space to run its contextual action: Enable, Disable, Apply Profile, or Resolve. Drift Resolution explicitly offers Reapply Active Profile and Restore Original; restoration refuses safely if proxyctl no longer has sufficient ownership evidence. Press `r` to refresh detection, `u` to show unavailable Targets, and `?` on any page or dialog for contextual Help. Help scrolls with arrows, `j`/`k`, Page Up/Page Down, Home, and End; `q` or Escape closes it without changing the page, focus, selection, or viewport beneath it. Applying or reapplying requires an Active Profile, while Restore Original remains available without one.
+10 -5
View File
@@ -140,12 +140,17 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
if model.form.currentField() == profile.BypassField {
body.WriteString("PgUp/PgDn scrolls the normalized Bypass preview while the Bypass field is focused.\n")
}
if model.form.profile.Mode == profile.SingleMode && model.form.currentField() == profile.SingleURLField {
if model.form.probing {
body.WriteString("Protocol Probe is in progress; Ctrl+D does not start a duplicate probe.\n")
} else {
body.WriteString("Ctrl+D explicitly runs Protocol Probe for the Single proxy URL field.\n")
if model.form.profile.Mode == profile.SingleMode {
switch {
case model.form.probe.active:
body.WriteString("Protocol Probe is in progress for the Single proxy URL field. Its progress remains attached to that field; Ctrl+D does not start a duplicate probe while that field is focused.\n")
case model.form.currentField() == profile.SingleURLField:
body.WriteString("Ctrl+D explicitly runs Protocol Probe for the focused Single proxy URL field. It never runs on form entry, focus changes, or save.\n")
default:
body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n")
}
} else if model.form.probe.feedback != "" {
body.WriteString("The Protocol Probe result remains attached to the generated Advanced Profile URL values.\n")
}
default:
body.WriteString(p.theme.section.Render("Dashboard"))
+40 -35
View File
@@ -40,21 +40,21 @@ type Model struct {
driftResolutionTargetID string
picker profilePickerState
form formState
formSequence uint64
help helpDialogState
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
}
// probeDone carries one finished Protocol Probe back to the form. address is
// always bare host:port so status text can never leak credentials. gen matches
// the probe generation stored in formState so late results can be ignored after
// the user leaves the form.
// probeDone carries one finished Protocol Probe back to the form. Endpoint and
// input text deliberately stay in formState: an asynchronous result cannot
// introduce userinfo into status, and formID plus generation reject stale work.
type probeDone struct {
address string
caps probe.Capabilities
err error
gen uint64
caps probe.Capabilities
err error
formID uint64
generation uint64
}
type profileDeleteConfirmation struct {
@@ -177,26 +177,30 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, waitForOperation(progress.updates)
}
if done, ok := message.(probeDone); ok {
if !m.form.probing || done.gen != m.form.probeGen {
if m.page != formPage || !m.form.probe.active || done.formID != m.form.id || done.generation != m.form.probe.generation {
return m, nil
}
m.form.probing = false
m.form.probe.active = false
address := m.form.probe.address
switch {
case done.err != nil:
m.status = "Protocol Probe: no proxy detected at " + done.address + ": " + done.err.Error()
// Detector errors are intentionally not rendered. Production errors are
// based on the credential-free dial endpoint, but this boundary also
// protects frames if a lower layer returns hostile credential text.
m.form.probe.feedback = "Protocol Probe failed for " + address
case !done.caps.HTTP && !done.caps.SOCKS5:
m.status = "Protocol Probe: unrecognized protocol at " + done.address
m.form.probe.feedback = "Protocol Probe: unrecognized protocol at " + address
default:
raw := m.form.profile.SingleURL
if done.caps.HTTP && done.caps.SOCKS5 {
httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true})
if err != nil {
m.status = "Protocol Probe: " + err.Error()
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true})
if err != nil {
m.status = "Protocol Probe: " + err.Error()
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
m.form.profile.Mode = profile.AdvancedMode
@@ -204,22 +208,24 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
m.form.profile.HTTPSURL = httpURL
m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
m.form.advancedInitialized = true
m.form.probe.field = profile.HTTPURLField
m.form.probe.feedback = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + address
m.form.rebuildInputs(1)
m.status = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + done.address
return m, nil
}
rewritten, err := probe.URL(raw, done.caps)
if err != nil {
m.status = "Protocol Probe: " + err.Error()
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
m.form.profile.SingleURL = rewritten
m.form.rebuildInputs(1)
m.form.probe.field = profile.SingleURLField
kind := "SOCKS5"
if done.caps.HTTP {
kind = "HTTP"
}
m.status = "Protocol Probe: detected " + kind + " proxy at " + done.address
m.form.probe.feedback = "Protocol Probe: detected " + kind + " proxy at " + address
m.form.rebuildInputs(1)
}
return m, nil
}
@@ -495,7 +501,8 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if !m.profileMutationAvailable("creating a Proxy Profile") {
break
}
m.form = newFormState("", profile.Profile{Mode: profile.SingleMode})
m.formSequence++
m.form = newFormState(m.formSequence, "", profile.Profile{Mode: profile.SingleMode})
m.page = formPage
m.status = ""
case "e", "enter":
@@ -503,7 +510,8 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
break
}
if selected, ok := m.selectedProfile(); ok {
m.form = newFormState(selected.Name, selected)
m.formSequence++
m.form = newFormState(m.formSequence, selected.Name, selected)
m.page = formPage
m.status = ""
}
@@ -567,12 +575,11 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
switch key.Type {
case tea.KeyCtrlS:
m.form.invalidateProbe()
m.form.syncFocusedValue()
m.form.refreshValidation()
if err := m.form.validation.Err(); err != nil || m.form.nameConflict(m.snapshot.Profiles) != "" {
m.status = "Fix the highlighted Profile fields before saving"
m.form.probeGen++
m.form.probing = false
return m, nil
}
kind := app.CreateProfile
@@ -583,34 +590,32 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: normalized}
result := m.applyProfileMutation(intent, normalized.Name)
if result.Err != nil {
m.form.probeGen++ // discard any in-flight probe result while staying on the form
m.form.probing = false
return m, nil
}
m.page = profilesPage
m.form = formState{}
return m, nil
case tea.KeyCtrlD:
if m.form.profile.Mode != profile.SingleMode {
m.status = "Protocol Probe: available in Single mode only"
return m, nil
}
if m.form.currentField() != profile.SingleURLField || m.form.probing {
if m.form.profile.Mode != profile.SingleMode || m.form.currentField() != profile.SingleURLField || m.form.probe.active {
return m, nil
}
m.form.syncFocusedValue()
m.form.invalidateProbe()
m.form.probe.field = profile.SingleURLField
address, err := probeAddress(m.form.profile.SingleURL)
if err != nil {
m.status = "Protocol Probe: " + err.Error()
m.form.probe.feedback = "Protocol Probe: " + err.Error()
return m, nil
}
m.form.probing = true
m.form.probeGen++
m.status = "Protocol Probe: checking " + address + "…"
m.form.probe.active = true
m.form.probe.address = address
m.form.probe.feedback = "Protocol Probe: checking " + address + "…"
detect := m.detect
gen := m.form.probeGen
formID := m.form.id
generation := m.form.probe.generation
return m, func() tea.Msg {
caps, err := detect(context.Background(), address)
return probeDone{address: address, caps: caps, err: err, gen: gen}
return probeDone{caps: caps, err: err, formID: formID, generation: generation}
}
case tea.KeyCtrlT:
return m, m.form.toggleMode()
+6 -10
View File
@@ -201,8 +201,8 @@ func TestProtocolProbeFailureLeavesFieldUntouchedAndSavePossible(t *testing.T) {
if got := model.form.profile.SingleURL; got != "localhost:8888" {
t.Fatalf("failed probe rewrote Single URL to %q", got)
}
if view := model.View(); !strings.Contains(view, "Protocol Probe: no proxy detected at localhost:8888: connection refused") {
t.Fatalf("form view missing failure status:\n%s", view)
if view := model.View(); !strings.Contains(view, "Protocol Probe failed for localhost:8888") || strings.Contains(view, "connection refused") {
t.Fatalf("form view missing credential-safe field failure status:\n%s", view)
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile {
@@ -286,10 +286,8 @@ func TestProtocolProbeLateResultIgnoredAfterEscLeavesForm(t *testing.T) {
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
gen := model.form.probeGen
model = updateKey(model, "esc")
updated, _ := model.Update(probeDone{address: "localhost:8888", caps: probe.Capabilities{HTTP: true}, gen: gen})
updated, _ := model.Update(cmd())
model = updated.(Model)
if model.page != profilesPage {
@@ -319,14 +317,12 @@ func TestProtocolProbeLateResultIgnoredAfterSaveLeavesForm(t *testing.T) {
if cmd == nil {
t.Fatal("ctrl+d did not start a Protocol Probe")
}
gen := model.form.probeGen
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("save dispatched %d intents, want 1", len(module.intents))
}
updated, _ := model.Update(probeDone{address: "localhost:8888", caps: probe.Capabilities{HTTP: true}, gen: gen})
updated, _ := model.Update(cmd())
model = updated.(Model)
if model.page != profilesPage {
@@ -480,8 +476,8 @@ func TestProtocolProbeInAdvancedModeShowsHintWithoutProbing(t *testing.T) {
if calls != 0 {
t.Fatalf("Advanced mode probe ran %d times, want 0", calls)
}
if view := model.View(); !strings.Contains(view, "Protocol Probe: available in Single mode only") {
t.Fatalf("form view missing Advanced mode hint:\n%s", view)
if view := model.View(); strings.Contains(view, "Protocol Probe") || strings.Contains(view, "Ctrl+D") {
t.Fatalf("Advanced mode advertised an inapplicable Protocol Probe action:\n%s", view)
}
}
+5 -1
View File
@@ -234,6 +234,10 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
focusLine = currentLine
}
fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, model.form.renderedInput(i, model.width))
if probeText := model.form.probeText(item.field); probeText != "" {
fmt.Fprintf(&fields, "\n %s", probeText)
currentLine++
}
validation := model.form.validationText(i)
if item.field == profile.NameField {
if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" {
@@ -262,7 +266,7 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
}
if model.form.profile.Mode == profile.SingleMode && model.form.currentField() == profile.SingleURLField {
probeHint := "Ctrl+D: probe"
if model.form.probing {
if model.form.probe.active {
probeHint = "Protocol Probe in progress"
}
secondaryHints = probeHint + " " + secondaryHints
+38 -3
View File
@@ -22,7 +22,16 @@ type formDefinition struct {
value string
}
type protocolProbeFieldState struct {
active bool
generation uint64
address string
feedback string
field profile.Field
}
type formState struct {
id uint64
originalName string
profile profile.Profile
inputs []formInput
@@ -30,15 +39,15 @@ type formState struct {
bypassPreviewLine int
validation profile.Validation
advancedInitialized bool
probing bool
probeGen uint64
probe protocolProbeFieldState
}
func newFormState(originalName string, value profile.Profile) formState {
func newFormState(id uint64, originalName string, value profile.Profile) formState {
if value.Mode == "" {
value.Mode = profile.SingleMode
}
form := formState{
id: id,
originalName: originalName,
profile: value,
advancedInitialized: value.Mode == profile.AdvancedMode,
@@ -125,7 +134,11 @@ func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd {
}
if f.inputs[f.field].input.Value() != previous {
f.bypassPreviewLine = 0
changedField := f.inputs[f.field].field
f.syncFocusedValue()
if changedField == profile.SingleURLField {
f.invalidateProbe()
}
f.refreshValidation()
}
return tea.Batch(commands...)
@@ -158,6 +171,7 @@ func (f *formState) refreshValidation() {
func (f *formState) toggleMode() tea.Cmd {
f.syncFocusedValue()
f.invalidateProbe()
if f.profile.Mode == profile.SingleMode {
f.profile.Mode = profile.AdvancedMode
if !f.advancedInitialized {
@@ -181,6 +195,27 @@ func (f formState) currentField() profile.Field {
return f.inputs[f.field].field
}
func (f *formState) invalidateProbe() {
f.probe.generation++
f.probe.active = false
f.probe.address = ""
f.probe.feedback = ""
f.probe.field = ""
}
func (f formState) probeText(field profile.Field) string {
if f.profile.Mode == profile.SingleMode && field == profile.SingleURLField {
if f.probe.feedback != "" {
return f.probe.feedback
}
return "Protocol Probe [Ctrl+D]: available"
}
if field == f.probe.field {
return f.probe.feedback
}
return ""
}
func (f *formState) scrollBypassPreview(delta int) {
if f.currentField() != profile.BypassField {
return
@@ -0,0 +1,370 @@
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 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 = "HTTP URL"
}
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 populated URL field:\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 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 TestCompleteModelProtocolProbeKeepsCredentialsOutOfDialStatusErrorsAndFrames(t *testing.T) {
const (
inputSecret = "input-secret"
errorSecret = "error-secret"
)
var dialed []string
model := openProtocolProbeEditor(t, &recordingModule{}, func(_ context.Context, address string) (probe.Capabilities, error) {
dialed = append(dialed, address)
return probe.Capabilities{}, errors.New("failed https://mallory:" + errorSecret + "@evil.example:9999")
}, "alice:"+inputSecret+"@proxy.example:8080")
model, command := startProtocolProbe(t, model)
progress := model.View()
if strings.Contains(progress, inputSecret) {
t.Fatalf("probe progress exposed credentials:\n%s", progress)
}
for _, line := range strings.Split(progress, "\n") {
if strings.Contains(line, "Protocol Probe") && strings.Contains(line, "alice") {
t.Fatalf("probe-generated progress exposed userinfo:\n%s", progress)
}
}
model = finishProtocolProbe(t, model, command)
view := model.View()
if len(dialed) != 1 || dialed[0] != "proxy.example:8080" {
t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed)
}
if strings.Contains(view, inputSecret) || strings.Contains(view, errorSecret) || strings.Contains(view, "mallory") {
t.Fatalf("probe frame exposed input or lower-layer credentials:\n%s", view)
}
assertUsableFrame(t, view, 80, 24, "Protocol Probe failed for proxy.example:8080")
}