pi-agent: Implement field Protocol Probe
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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