pi-agent: Implement field Protocol Probe
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user