pi-agent: Implement operation coordination
This commit is contained in:
@@ -172,7 +172,9 @@ func TestCompleteModelRedactsApplicationErrorsBeforeRendering(t *testing.T) {
|
||||
updated, cmd := model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
updated, _ = model.Update(cmd())
|
||||
view := updated.(Model).View()
|
||||
model = updated.(Model)
|
||||
model = updateKey(model, "e")
|
||||
view := model.View()
|
||||
if strings.Contains(view, secret) || !strings.Contains(view, "https://bob:REDACTED@proxy.example:8080") {
|
||||
t.Fatalf("Application error bypassed presentation redaction:\n%s", view)
|
||||
}
|
||||
|
||||
+236
-68
@@ -48,6 +48,15 @@ type Model struct {
|
||||
profileConfirm *app.Intent
|
||||
pendingActivation app.Intent
|
||||
status string
|
||||
operationStatus app.OperationStatus
|
||||
currentError *app.OperationStatus
|
||||
errorExpanded bool
|
||||
confirmation *app.Confirmation
|
||||
operationActive bool
|
||||
operationKind app.IntentKind
|
||||
operationIntent app.Intent
|
||||
operationUpdates <-chan app.OperationUpdate
|
||||
operationProgress map[string]app.TargetProgressPhase
|
||||
applying bool
|
||||
refreshing bool
|
||||
detectionUpdates <-chan app.TargetDetectionUpdate
|
||||
@@ -76,6 +85,12 @@ type profileOperationProgress struct {
|
||||
updates <-chan app.Result
|
||||
}
|
||||
|
||||
type operationProgress struct {
|
||||
update app.OperationUpdate
|
||||
updates <-chan app.OperationUpdate
|
||||
closed bool
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -97,6 +112,16 @@ func waitForTargetDetection(updates <-chan app.TargetDetectionUpdate) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func waitForOperation(updates <-chan app.OperationUpdate) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
update, ok := <-updates
|
||||
if !ok {
|
||||
return operationProgress{updates: updates, closed: true}
|
||||
}
|
||||
return operationProgress{update: update, updates: updates}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
result, ok := <-updates
|
||||
@@ -113,26 +138,35 @@ func NewModel(application app.Module, configure ...ModelOption) Model {
|
||||
option(&options)
|
||||
}
|
||||
snapshot := application.Snapshot()
|
||||
var detectionUpdates <-chan app.TargetDetectionUpdate
|
||||
if progressive, ok := application.(app.ProgressiveDetectionModule); ok {
|
||||
snapshot, detectionUpdates = progressive.BeginTargetDetection()
|
||||
model := Model{
|
||||
application: application,
|
||||
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
|
||||
snapshot: snapshot,
|
||||
selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(),
|
||||
width: 80,
|
||||
height: 24,
|
||||
operationProgress: make(map[string]app.TargetProgressPhase),
|
||||
detect: probe.Detect,
|
||||
}
|
||||
return Model{
|
||||
application: application,
|
||||
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
|
||||
snapshot: snapshot,
|
||||
selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(),
|
||||
width: 80,
|
||||
height: 24,
|
||||
refreshing: detectionUpdates != nil,
|
||||
detectionUpdates: detectionUpdates,
|
||||
detect: probe.Detect,
|
||||
_, progressiveDetection := application.(app.ProgressiveDetectionModule)
|
||||
if targetsChecking(snapshot) || progressiveDetection {
|
||||
model.startOperation(app.Intent{Kind: app.RefreshTargets})
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func targetsChecking(snapshot app.Snapshot) bool {
|
||||
for _, item := range snapshot.Targets {
|
||||
if item.DetectionPhase == app.TargetChecking {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
if m.detectionUpdates != nil {
|
||||
return waitForTargetDetection(m.detectionUpdates)
|
||||
if m.operationUpdates != nil {
|
||||
return waitForOperation(m.operationUpdates)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -145,11 +179,11 @@ func (m Model) busy() bool {
|
||||
}
|
||||
|
||||
func (m Model) operationInFlight() bool {
|
||||
return m.applying || m.refreshing || m.profileTransition
|
||||
return m.operationActive || m.applying || m.refreshing || m.profileTransition
|
||||
}
|
||||
|
||||
func (m Model) dashboardNavigationAvailable() bool {
|
||||
return !m.quitRequested && !m.applying && !m.profileTransition
|
||||
return !m.quitRequested
|
||||
}
|
||||
|
||||
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -166,9 +200,32 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.quitRequested && !m.operationInFlight() {
|
||||
return m, nil
|
||||
}
|
||||
if key, ok := message.(tea.KeyMsg); ok && m.operationInFlight() && isQuitKey(key) {
|
||||
m.quitRequested = true
|
||||
return m, nil
|
||||
if key, ok := message.(tea.KeyMsg); ok {
|
||||
exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && key.String() == "q" && m.targetConfirm == nil && m.profileConfirm == nil)
|
||||
if exitRequest {
|
||||
if m.operationInFlight() {
|
||||
m.quitRequested = true
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
if progress, ok := message.(operationProgress); ok {
|
||||
if !m.operationActive || progress.updates != m.operationUpdates {
|
||||
return m, nil
|
||||
}
|
||||
if progress.closed {
|
||||
m.snapshot = m.application.Snapshot()
|
||||
m.retainTargetSelection()
|
||||
m.finishOperation()
|
||||
return m, m.deferredQuit()
|
||||
}
|
||||
m.applyOperationUpdate(progress.update)
|
||||
if isTerminalOperationPhase(progress.update.Phase) {
|
||||
m.finishOperation()
|
||||
return m, m.deferredQuit()
|
||||
}
|
||||
return m, waitForOperation(progress.updates)
|
||||
}
|
||||
if progress, ok := message.(targetDetectionProgress); ok {
|
||||
if !m.refreshing || progress.updates != m.detectionUpdates {
|
||||
@@ -338,6 +395,106 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) startOperation(intent app.Intent) tea.Cmd {
|
||||
if m.quitRequested {
|
||||
return nil
|
||||
}
|
||||
if m.operationActive {
|
||||
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before starting another modification"}
|
||||
return nil
|
||||
}
|
||||
interactive, ok := m.application.(app.InteractiveModule)
|
||||
if !ok {
|
||||
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Interactive operation streaming is unavailable"}
|
||||
return nil
|
||||
}
|
||||
stream := interactive.StartOperation(intent)
|
||||
m.snapshot = stream.Snapshot
|
||||
m.retainTargetSelection()
|
||||
m.operationActive = true
|
||||
m.operationKind = intent.Kind
|
||||
m.operationIntent = intent
|
||||
m.operationUpdates = stream.Updates
|
||||
m.operationProgress = make(map[string]app.TargetProgressPhase)
|
||||
m.applying = intent.Kind == app.SetTargetDesired || intent.Kind == app.ActivateProfile
|
||||
m.refreshing = intent.Kind == app.RefreshTargets
|
||||
m.profileTransition = intent.Kind == app.ActivateProfile
|
||||
m.progressTarget = ""
|
||||
m.operationStatus = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)}
|
||||
return waitForOperation(stream.Updates)
|
||||
}
|
||||
|
||||
func operationStartingSummary(kind app.IntentKind) string {
|
||||
switch kind {
|
||||
case app.RefreshTargets:
|
||||
return "Checking Target availability and state…"
|
||||
case app.ActivateProfile:
|
||||
return "Changing Active Profile…"
|
||||
default:
|
||||
return "Starting Target mutation…"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) finishOperation() {
|
||||
m.operationActive = false
|
||||
m.operationKind = 0
|
||||
m.operationIntent = app.Intent{}
|
||||
m.operationUpdates = nil
|
||||
m.applying = false
|
||||
m.refreshing = false
|
||||
m.profileTransition = false
|
||||
m.progressTarget = ""
|
||||
}
|
||||
|
||||
func isTerminalOperationPhase(phase app.OperationPhase) bool {
|
||||
return phase == app.OperationAwaitingConfirmation || phase == app.OperationSucceeded || phase == app.OperationFailed || phase == app.OperationBlocked
|
||||
}
|
||||
|
||||
func (m *Model) applyOperationUpdate(update app.OperationUpdate) {
|
||||
m.snapshot = update.Snapshot
|
||||
m.retainTargetSelection()
|
||||
for _, progress := range update.Progress {
|
||||
m.operationProgress[progress.TargetID] = progress.Phase
|
||||
if progress.Phase == app.TargetProgressRunning {
|
||||
m.progressTarget = progress.TargetID
|
||||
} else if m.progressTarget == progress.TargetID {
|
||||
m.progressTarget = ""
|
||||
}
|
||||
}
|
||||
if update.Status.Kind != 0 {
|
||||
m.operationStatus = update.Status
|
||||
}
|
||||
if update.Status.Kind == app.StatusError {
|
||||
current := update.Status
|
||||
if current.Detail == "" && update.Err != nil {
|
||||
current.Detail = update.Err.Error()
|
||||
}
|
||||
m.currentError = ¤t
|
||||
m.errorExpanded = false
|
||||
} else if update.Phase == app.OperationSucceeded && m.currentError != nil && update.Status.TargetID != "" && update.Status.TargetID == m.currentError.TargetID {
|
||||
m.currentError = nil
|
||||
m.errorExpanded = false
|
||||
}
|
||||
if update.FirstFailedTargetID != "" {
|
||||
m.selectedTargetID = update.FirstFailedTargetID
|
||||
m.detailsOffset = 0
|
||||
}
|
||||
if isTerminalOperationPhase(update.Phase) && update.Phase != app.OperationAwaitingConfirmation {
|
||||
m.pendingActivation = app.Intent{}
|
||||
}
|
||||
if update.Phase == app.OperationAwaitingConfirmation && update.Confirmation != nil {
|
||||
intent := m.operationIntent
|
||||
m.confirmation = update.Confirmation
|
||||
m.page = mainPage
|
||||
if intent.Kind == app.ActivateProfile {
|
||||
m.pendingActivation = intent
|
||||
m.profileConfirm = &intent
|
||||
} else {
|
||||
m.targetConfirm = &intent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isQuitKey(key tea.KeyMsg) bool {
|
||||
switch key.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
@@ -355,46 +512,58 @@ func (m Model) deferredQuit() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.targetConfirm != nil && !m.busy() {
|
||||
if m.targetConfirm != nil || m.profileConfirm != nil {
|
||||
intent := m.targetConfirm
|
||||
if intent == nil {
|
||||
intent = m.profileConfirm
|
||||
}
|
||||
switch key.String() {
|
||||
case "y":
|
||||
intent := *m.targetConfirm
|
||||
intent.Confirm = true
|
||||
confirmed := *intent
|
||||
confirmed.Confirm = true
|
||||
m.targetConfirm = nil
|
||||
m.applying = true
|
||||
m.profileConfirm = nil
|
||||
m.pendingActivation = app.Intent{}
|
||||
m.confirmation = nil
|
||||
m.status = ""
|
||||
return m, func() tea.Msg { return targetOperationDone{result: m.application.Dispatch(intent)} }
|
||||
case "n", "esc", "q":
|
||||
return m, m.startOperation(confirmed)
|
||||
case "enter", "n", "esc", "q":
|
||||
m.targetConfirm = nil
|
||||
m.status = ""
|
||||
m.profileConfirm = nil
|
||||
m.pendingActivation = app.Intent{}
|
||||
m.confirmation = nil
|
||||
m.operationStatus = app.OperationStatus{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if key.String() == "e" && m.currentError != nil {
|
||||
m.errorExpanded = !m.errorExpanded
|
||||
return m, nil
|
||||
}
|
||||
if key.String() == "x" && m.currentError != nil {
|
||||
m.currentError = nil
|
||||
m.errorExpanded = false
|
||||
return m, nil
|
||||
}
|
||||
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
|
||||
switch key.String() {
|
||||
case "p":
|
||||
if !m.busy() {
|
||||
if m.operationActive || m.quitRequested {
|
||||
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before opening Profile management"}
|
||||
} else {
|
||||
m.page = profilesPage
|
||||
m.selected = 0
|
||||
m.status = ""
|
||||
}
|
||||
case "?":
|
||||
if !m.busy() {
|
||||
if !m.quitRequested {
|
||||
m.page = helpPage
|
||||
m.status = ""
|
||||
}
|
||||
case "r":
|
||||
if !m.busy() {
|
||||
m.refreshing = true
|
||||
m.status = "Refreshing Target availability and state…"
|
||||
if progressive, ok := m.application.(app.ProgressiveDetectionModule); ok {
|
||||
m.snapshot, m.detectionUpdates = progressive.BeginTargetDetection()
|
||||
m.retainTargetSelection()
|
||||
return m, waitForTargetDetection(m.detectionUpdates)
|
||||
}
|
||||
return m, func() tea.Msg {
|
||||
return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})}
|
||||
}
|
||||
if m.operationActive || m.quitRequested {
|
||||
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before refreshing Targets"}
|
||||
} else {
|
||||
return m, m.startOperation(app.Intent{Kind: app.RefreshTargets})
|
||||
}
|
||||
case "tab":
|
||||
if m.dashboardNavigationAvailable() {
|
||||
@@ -438,22 +607,21 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.detailsOffset += 5
|
||||
}
|
||||
case " ":
|
||||
if !m.busy() {
|
||||
selected, ok := catalog.selected(m.selectedTargetID)
|
||||
if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady {
|
||||
break
|
||||
}
|
||||
desired := selected.State == target.Disabled || selected.State == target.External
|
||||
m.applying = true
|
||||
m.status = ""
|
||||
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
|
||||
m.targetConfirm = &intent
|
||||
return m, func() tea.Msg {
|
||||
return targetOperationDone{result: m.application.Dispatch(intent)}
|
||||
}
|
||||
if m.operationActive || m.quitRequested {
|
||||
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before starting another modification"}
|
||||
break
|
||||
}
|
||||
selected, ok := catalog.selected(m.selectedTargetID)
|
||||
if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady {
|
||||
break
|
||||
}
|
||||
desired := selected.State == target.Disabled || selected.State == target.External
|
||||
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
|
||||
return m, m.startOperation(intent)
|
||||
case "esc":
|
||||
if !m.operationActive {
|
||||
return m, tea.Quit
|
||||
}
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -610,20 +778,13 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
|
||||
if m.busy() {
|
||||
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"}
|
||||
return m, nil
|
||||
}
|
||||
if progressive, ok := m.application.(app.ProgressiveModule); ok {
|
||||
m.applying = true
|
||||
m.profileTransition = true
|
||||
m.pendingActivation = intent
|
||||
m.page = mainPage
|
||||
m.selected = 0
|
||||
m.status = "Changing Active Profile…"
|
||||
updates := progressive.DispatchProgress(intent)
|
||||
return m, waitForProfileProgress(updates)
|
||||
}
|
||||
m.apply(intent)
|
||||
return m, nil
|
||||
m.pendingActivation = intent
|
||||
m.page = mainPage
|
||||
m.selected = 0
|
||||
return m, m.startOperation(intent)
|
||||
}
|
||||
|
||||
func (m *Model) apply(intent app.Intent) {
|
||||
@@ -683,7 +844,14 @@ func (m Model) View() string {
|
||||
confirm: m.confirm,
|
||||
targetConfirm: m.targetConfirm != nil,
|
||||
profileConfirm: m.profileConfirm != nil,
|
||||
confirmation: m.confirmation,
|
||||
status: m.status,
|
||||
operationStatus: m.operationStatus,
|
||||
currentError: m.currentError,
|
||||
errorExpanded: m.errorExpanded,
|
||||
exitDeferred: m.quitRequested && m.operationInFlight(),
|
||||
operationActive: m.operationActive,
|
||||
operationKind: m.operationKind,
|
||||
applying: m.applying,
|
||||
profileTransition: m.profileTransition,
|
||||
progressTarget: m.progressTarget,
|
||||
|
||||
@@ -23,6 +23,19 @@ func (m *recordingModule) Dispatch(intent app.Intent) app.Result {
|
||||
m.intents = append(m.intents, intent)
|
||||
return app.Result{Snapshot: m.snapshot, Notice: "done"}
|
||||
}
|
||||
func (m *recordingModule) StartOperation(intent app.Intent) app.OperationStream {
|
||||
result := m.Dispatch(intent)
|
||||
updates := make(chan app.OperationUpdate, 1)
|
||||
phase := app.OperationSucceeded
|
||||
status := app.OperationStatus{Kind: app.StatusSuccess, Summary: result.Notice}
|
||||
if result.Err != nil {
|
||||
phase = app.OperationFailed
|
||||
status = app.OperationStatus{Kind: app.StatusError, Summary: "Operation failed", Detail: result.Err.Error()}
|
||||
}
|
||||
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: status, Err: result.Err}
|
||||
close(updates)
|
||||
return app.OperationStream{Snapshot: m.snapshot, Updates: updates}
|
||||
}
|
||||
|
||||
func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
|
||||
module := &recordingModule{snapshot: app.Snapshot{
|
||||
@@ -46,10 +59,14 @@ func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
|
||||
t.Fatalf("Profile page exposed proxy password:\n%s", view)
|
||||
}
|
||||
|
||||
model = updateKey(model, "a")
|
||||
if len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "office" {
|
||||
updated, command := model.Update(keyMessage("a"))
|
||||
model = updated.(Model)
|
||||
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "office" {
|
||||
t.Fatalf("activate key dispatched %#v", module.intents)
|
||||
}
|
||||
updated, _ = model.Update(command())
|
||||
model = updated.(Model)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "d")
|
||||
if !strings.Contains(model.View(), "Delete Profile") {
|
||||
t.Fatalf("delete key did not request confirmation:\n%s", model.View())
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
type interactiveScriptModule struct {
|
||||
snapshot app.Snapshot
|
||||
streams []chan app.OperationUpdate
|
||||
intents []app.Intent
|
||||
}
|
||||
|
||||
func (m *interactiveScriptModule) Snapshot() app.Snapshot { return m.snapshot }
|
||||
func (m *interactiveScriptModule) Dispatch(intent app.Intent) app.Result {
|
||||
m.intents = append(m.intents, intent)
|
||||
return app.Result{Snapshot: m.snapshot}
|
||||
}
|
||||
func (m *interactiveScriptModule) StartOperation(intent app.Intent) app.OperationStream {
|
||||
m.intents = append(m.intents, intent)
|
||||
stream := m.streams[0]
|
||||
m.streams = m.streams[1:]
|
||||
return app.OperationStream{Snapshot: m.snapshot, Updates: stream}
|
||||
}
|
||||
|
||||
func operationSnapshot() app.Snapshot {
|
||||
return app.Snapshot{
|
||||
ActiveProfile: "office",
|
||||
Profiles: []profile.Profile{{Name: "office"}, {Name: "remote"}},
|
||||
Targets: []app.TargetSnapshot{
|
||||
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell details"}, Available: true, State: target.Disabled},
|
||||
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT details"}, Available: true, State: target.Enabled},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) {
|
||||
t.Helper()
|
||||
if command == nil {
|
||||
t.Fatal("operation command is nil")
|
||||
}
|
||||
updated, next := model.Update(command())
|
||||
return updated.(Model), next
|
||||
}
|
||||
|
||||
func TestCompleteModelKeepsDashboardAndHelpBrowsableWhileMutationIsSerialized(t *testing.T) {
|
||||
updates := make(chan app.OperationUpdate, 2)
|
||||
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
updates <- app.OperationUpdate{Snapshot: module.snapshot, Phase: app.OperationRunning,
|
||||
Status: app.OperationStatus{Kind: app.StatusProgress, Summary: "Writing Shell configuration", TargetID: "shell"},
|
||||
Progress: []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}}}
|
||||
model, command = applyOperationCommand(t, model, command)
|
||||
assertUsableFrame(t, model.View(), 120, 36, "Applying", "Writing Shell configuration")
|
||||
|
||||
model = updateKey(model, "down")
|
||||
if model.selectedTargetID != "apt" {
|
||||
t.Fatalf("selection while busy = %q, want apt", model.selectedTargetID)
|
||||
}
|
||||
model = updateKey(model, "tab")
|
||||
model = updateKey(model, "down")
|
||||
model = updateKey(model, "?")
|
||||
if model.page != helpPage || !strings.Contains(model.View(), "proxyctl Help") {
|
||||
t.Fatalf("Help was not reachable while mutation ran:\n%s", model.View())
|
||||
}
|
||||
model = updateKey(model, "q")
|
||||
if model.page != mainPage {
|
||||
t.Fatal("q did not return from Help while mutation ran")
|
||||
}
|
||||
|
||||
updated, blocked := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
if blocked != nil || !strings.Contains(model.View(), "Wait for the current mutation to finish") || len(module.intents) != 1 {
|
||||
t.Fatalf("conflicting mutation was not explained: intents=%#v\n%s", module.intents, model.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelPreservesExpandableCurrentErrorAndFocusesFirstFailedTarget(t *testing.T) {
|
||||
failure := make(chan app.OperationUpdate, 1)
|
||||
refresh := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, refresh}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
failedSnapshot := operationSnapshot()
|
||||
failedSnapshot.Targets[0].State = target.Enabled
|
||||
failedSnapshot.Targets[1].State = target.Drifted
|
||||
failure <- app.OperationUpdate{Snapshot: failedSnapshot, Phase: app.OperationFailed, FirstFailedTargetID: "apt", Err: errors.New("apt write failed: permission denied"),
|
||||
Status: app.OperationStatus{Kind: app.StatusError, Summary: "Active Profile changed with one Target failure", Detail: "apt write failed: permission denied", TargetID: "apt"},
|
||||
Progress: []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}, {TargetID: "apt", Phase: app.TargetProgressFailed}}}
|
||||
close(failure)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
if model.selectedTargetID != "apt" {
|
||||
t.Fatalf("first failed Target was not selected: %q", model.selectedTargetID)
|
||||
}
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "Current error: Active Profile changed with one Target failure") || strings.Contains(view, "permission denied") {
|
||||
t.Fatalf("collapsed current error is wrong:\n%s", view)
|
||||
}
|
||||
model = updateKey(model, "e")
|
||||
if !strings.Contains(model.View(), "apt write failed: permission denied") {
|
||||
t.Fatalf("current error did not expand:\n%s", model.View())
|
||||
}
|
||||
|
||||
updated, command = model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
refresh <- app.OperationUpdate{Snapshot: failedSnapshot, Phase: app.OperationSucceeded,
|
||||
Status: app.OperationStatus{Kind: app.StatusSuccess, Summary: "Target checks completed"}}
|
||||
close(refresh)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
if !strings.Contains(model.View(), "Current error: Active Profile changed with one Target failure") {
|
||||
t.Fatalf("unrelated success replaced current error:\n%s", model.View())
|
||||
}
|
||||
model = updateKey(model, "x")
|
||||
if strings.Contains(model.View(), "Current error:") {
|
||||
t.Fatalf("acknowledged error remained visible:\n%s", model.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelUsesStructuredConfirmationAndSafeCancellation(t *testing.T) {
|
||||
confirmation := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{confirmation}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
confirmation <- app.OperationUpdate{Snapshot: module.snapshot, Phase: app.OperationAwaitingConfirmation,
|
||||
Status: app.OperationStatus{Kind: app.StatusConfirmation, Summary: "Confirm Docker restart"},
|
||||
Confirmation: &app.Confirmation{Title: "Confirm Docker restart", Summary: "Running work may be disrupted.", ConfirmLabel: "Restart", CancelLabel: "Cancel", HighImpact: true}}
|
||||
close(confirmation)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
view := model.View()
|
||||
assertUsableFrame(t, view, 120, 36, "Confirm Docker restart", "Running work may be disrupted", "Enter/n/Esc: Cancel")
|
||||
|
||||
updated, next := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
if next != nil || model.targetConfirm != nil || len(module.intents) != 1 {
|
||||
t.Fatalf("safe-default Enter did not cancel: confirmation=%v intents=%#v", model.targetConfirm != nil, module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelVisiblyDefersCtrlCFromHelpAndStartsNoNewWork(t *testing.T) {
|
||||
updates := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
model = updateKey(model, "?")
|
||||
|
||||
updated, quit := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
||||
model = updated.(Model)
|
||||
if quit != nil || !strings.Contains(model.View(), "Exit requested — waiting for the current mutation to finish safely") {
|
||||
t.Fatalf("deferred exit was not visible from Help:\n%s", model.View())
|
||||
}
|
||||
for _, key := range []tea.KeyMsg{tea.KeyMsg{Type: tea.KeySpace}, keyMessage("r"), keyMessage("p")} {
|
||||
updated, command := model.Update(key)
|
||||
model = updated.(Model)
|
||||
if command != nil || len(module.intents) != 1 {
|
||||
t.Fatalf("new work began after exit request: intents=%#v", module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
updates <- app.OperationUpdate{Snapshot: module.snapshot, Phase: app.OperationSucceeded,
|
||||
Status: app.OperationStatus{Kind: app.StatusSuccess, Summary: "Shell Target is now Enabled", TargetID: "shell"}}
|
||||
close(updates)
|
||||
model, quit = applyOperationCommand(t, model, operation)
|
||||
if quit == nil {
|
||||
t.Fatal("safe completion did not queue exit")
|
||||
}
|
||||
if _, ok := quit().(tea.QuitMsg); !ok {
|
||||
t.Fatal("safe completion did not return tea.Quit")
|
||||
}
|
||||
}
|
||||
+38
-6
@@ -22,10 +22,13 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
|
||||
|
||||
catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID)
|
||||
footer := p.dashboardFooter(model, catalog)
|
||||
if model.targetConfirm && !model.applying {
|
||||
footer = "y: confirm n/Esc: cancel"
|
||||
if model.targetConfirm && !model.operationActive {
|
||||
footer = "y: confirm Enter/n/Esc: Cancel"
|
||||
}
|
||||
return terminalPage{
|
||||
if model.currentError != nil {
|
||||
footer += "\ne: expand/collapse current error x: acknowledge error"
|
||||
}
|
||||
page := terminalPage{
|
||||
header: header,
|
||||
footer: footer,
|
||||
dashboard: &dashboardRenderState{
|
||||
@@ -38,21 +41,50 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
|
||||
progressTarget: model.progressTarget,
|
||||
},
|
||||
}
|
||||
if model.confirmation != nil {
|
||||
var details strings.Builder
|
||||
details.WriteString(p.theme.section.Render(model.confirmation.Title))
|
||||
if model.confirmation.Summary != "" {
|
||||
fmt.Fprintf(&details, "\n%s", model.confirmation.Summary)
|
||||
}
|
||||
for _, detail := range model.confirmation.Details {
|
||||
fmt.Fprintf(&details, "\n• %s", detail)
|
||||
}
|
||||
page.dashboard = nil
|
||||
page.primary = details.String()
|
||||
page.context = "Cancellation is selected by default."
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
|
||||
if model.exitDeferred {
|
||||
return "Exit pending; browsing remains safe while the current mutation completes"
|
||||
}
|
||||
visibility := "u: show unavailable"
|
||||
if model.showUnavailable {
|
||||
visibility = "u: hide unavailable"
|
||||
}
|
||||
if model.dashboardFocus == targetDetailsPane {
|
||||
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: manage Proxy Profiles ?: help q/Esc: quit"
|
||||
footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: manage Proxy Profiles ?: help q/Esc: quit"
|
||||
if model.operationActive {
|
||||
footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets ?: help\nMutations disabled: wait for the current mutation to finish"
|
||||
}
|
||||
return footer
|
||||
}
|
||||
actions := "↑/k ↓/j: select Tab: focus details " + visibility
|
||||
if selected, ok := catalog.selected(model.selectedTargetID); ok && selected.Available && selected.DetectionPhase == app.TargetReady {
|
||||
actions = "Space: toggle " + actions
|
||||
if model.operationActive {
|
||||
actions = "Space: disabled (mutation in progress) " + actions
|
||||
} else {
|
||||
actions = "Space: toggle " + actions
|
||||
}
|
||||
}
|
||||
return actions + "\np: manage Proxy Profiles r: refresh ?: help q/Esc: quit"
|
||||
footer := actions + "\np: manage Proxy Profiles r: refresh ?: help q/Esc: quit"
|
||||
if model.operationActive {
|
||||
footer = actions + "\n?: help q: defer safe exit; new mutations disabled"
|
||||
}
|
||||
return footer
|
||||
}
|
||||
|
||||
func (p terminalPresentation) helpView(model presentationState) terminalPage {
|
||||
|
||||
@@ -66,7 +66,14 @@ type presentationState struct {
|
||||
confirm bool
|
||||
targetConfirm bool
|
||||
profileConfirm bool
|
||||
confirmation *app.Confirmation
|
||||
status string
|
||||
operationStatus app.OperationStatus
|
||||
currentError *app.OperationStatus
|
||||
errorExpanded bool
|
||||
exitDeferred bool
|
||||
operationActive bool
|
||||
operationKind app.IntentKind
|
||||
applying bool
|
||||
profileTransition bool
|
||||
progressTarget string
|
||||
@@ -115,7 +122,7 @@ func (p terminalPresentation) Render(state presentationState) string {
|
||||
default:
|
||||
page = p.mainView(state, width, height)
|
||||
}
|
||||
page.status = state.status
|
||||
page.status = renderStatus(state)
|
||||
page = redactTerminalPage(page)
|
||||
// Dashboard composition retains structured Target metadata until pane sizes
|
||||
// are known. Redact the fully composed frame as the final presentation
|
||||
@@ -123,6 +130,25 @@ func (p terminalPresentation) Render(state presentationState) string {
|
||||
return profile.RedactText(p.renderShell(page, width, height))
|
||||
}
|
||||
|
||||
func renderStatus(state presentationState) string {
|
||||
var lines []string
|
||||
if state.exitDeferred {
|
||||
lines = append(lines, "Exit requested — waiting for the current mutation to finish safely")
|
||||
} else if state.operationStatus.Summary != "" {
|
||||
lines = append(lines, state.operationStatus.Summary)
|
||||
} else if state.status != "" {
|
||||
lines = append(lines, state.status)
|
||||
}
|
||||
if state.currentError != nil {
|
||||
errorText := "Current error: " + state.currentError.Summary
|
||||
if state.errorExpanded && state.currentError.Detail != "" {
|
||||
errorText += " — " + state.currentError.Detail
|
||||
}
|
||||
lines = append(lines, errorText)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func redactTerminalPage(page terminalPage) terminalPage {
|
||||
page.header = profile.RedactText(page.header)
|
||||
page.primary = profile.RedactText(page.primary)
|
||||
|
||||
@@ -32,6 +32,23 @@ func (m *progressiveDetectionModule) BeginTargetDetection() (app.Snapshot, <-cha
|
||||
}
|
||||
return cloneDetectionSnapshot(m.snapshot), m.streams[m.begins-1]
|
||||
}
|
||||
func (m *progressiveDetectionModule) StartOperation(intent app.Intent) app.OperationStream {
|
||||
initial, detection := m.BeginTargetDetection()
|
||||
updates := make(chan app.OperationUpdate, len(initial.Targets)+1)
|
||||
go func() {
|
||||
defer close(updates)
|
||||
for update := range detection {
|
||||
phase := app.OperationRunning
|
||||
status := app.OperationStatus{Kind: app.StatusProgress, Summary: "Checking Targets"}
|
||||
if update.Complete {
|
||||
phase = app.OperationSucceeded
|
||||
status = app.OperationStatus{Kind: app.StatusSuccess, Summary: "Target checks completed"}
|
||||
}
|
||||
updates <- app.OperationUpdate{Snapshot: update.Snapshot, Phase: phase, Status: status}
|
||||
}
|
||||
}()
|
||||
return app.OperationStream{Snapshot: initial, Updates: updates}
|
||||
}
|
||||
|
||||
func TestCompleteModelStreamsStartupAndRefreshByTargetWithoutMovingSelection(t *testing.T) {
|
||||
startup := make(chan app.TargetDetectionUpdate, 4)
|
||||
|
||||
+77
-94
@@ -25,6 +25,28 @@ func (m *targetModule) Dispatch(intent app.Intent) app.Result {
|
||||
m.snapshot = m.result.Snapshot
|
||||
return m.result
|
||||
}
|
||||
func (m *targetModule) StartOperation(intent app.Intent) app.OperationStream {
|
||||
result := m.Dispatch(intent)
|
||||
updates := make(chan app.OperationUpdate, 1)
|
||||
phase := app.OperationSucceeded
|
||||
status := app.OperationStatus{Kind: app.StatusSuccess, Summary: result.Notice, TargetID: intent.TargetID}
|
||||
if result.ConfirmationRequest != nil || result.Confirmation != "" {
|
||||
phase = app.OperationAwaitingConfirmation
|
||||
confirmation := result.ConfirmationRequest
|
||||
if confirmation == nil {
|
||||
confirmation = &app.Confirmation{Title: "Confirm Target change", Summary: result.Confirmation, ConfirmLabel: "Continue", CancelLabel: "Cancel"}
|
||||
}
|
||||
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: app.OperationStatus{Kind: app.StatusConfirmation, Summary: confirmation.Title}, Confirmation: confirmation}
|
||||
} else {
|
||||
if result.Err != nil {
|
||||
phase = app.OperationFailed
|
||||
status = app.OperationStatus{Kind: app.StatusError, Summary: "Operation failed", Detail: result.Err.Error(), TargetID: intent.TargetID}
|
||||
}
|
||||
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: status, Err: result.Err}
|
||||
}
|
||||
close(updates)
|
||||
return app.OperationStream{Snapshot: m.snapshot, Updates: updates}
|
||||
}
|
||||
|
||||
func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing.T) {
|
||||
module := &targetModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{
|
||||
@@ -40,7 +62,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
|
||||
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
if command == nil || !strings.Contains(model.View(), "Applying") || len(module.intents) != 0 {
|
||||
if command == nil || !strings.Contains(model.View(), "Applying") || len(module.intents) != 1 {
|
||||
t.Fatalf("operation did not expose applying state before effect:\n%s", model.View())
|
||||
}
|
||||
message := command()
|
||||
@@ -113,7 +135,7 @@ func TestRefreshKeyRunsAsynchronouslyAndRendersRefreshingThenCompleted(t *testin
|
||||
model := NewModel(module)
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
|
||||
model = updated.(Model)
|
||||
if command == nil || len(module.intents) != 0 || !strings.Contains(model.View(), "Refreshing Target availability and state") {
|
||||
if command == nil || len(module.intents) != 1 || !strings.Contains(model.View(), "Checking Target availability and state") {
|
||||
t.Fatalf("refresh did not start asynchronously: intents %#v\n%s", module.intents, model.View())
|
||||
}
|
||||
message := command()
|
||||
@@ -141,19 +163,17 @@ func TestBusyActivationBlocksPageEntryAndEveryNewOperation(t *testing.T) {
|
||||
t.Fatalf("activation did not start exactly once: command=%v intents=%#v", command != nil, module.intents)
|
||||
}
|
||||
|
||||
for _, key := range []string{"p", "?", "r", " "} {
|
||||
for _, key := range []string{"p", "r", " "} {
|
||||
updated, blocked := model.Update(keyMessage(key))
|
||||
model = updated.(Model)
|
||||
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
|
||||
t.Fatalf("busy activation accepted %q: page=%v command=%v intents=%#v", key, model.page, blocked != nil, module.intents)
|
||||
}
|
||||
}
|
||||
for _, key := range []tea.KeyMsg{keyMessage("q"), {Type: tea.KeyEsc}, {Type: tea.KeyCtrlC}} {
|
||||
updated, quit := model.Update(key)
|
||||
deferred := updated.(Model)
|
||||
if quit != nil || !deferred.quitRequested || len(module.intents) != 1 {
|
||||
t.Fatalf("busy activation did not defer quit %q: command=%v requested=%v intents=%#v", key.String(), quit != nil, deferred.quitRequested, module.intents)
|
||||
}
|
||||
updated, quit := model.Update(keyMessage("q"))
|
||||
model = updated.(Model)
|
||||
if quit != nil || !model.quitRequested || len(module.intents) != 1 {
|
||||
t.Fatalf("busy activation did not defer quit: command=%v requested=%v intents=%#v", quit != nil, model.quitRequested, module.intents)
|
||||
}
|
||||
|
||||
// Even if a Profiles-page activation key is delivered while the transition
|
||||
@@ -179,25 +199,17 @@ func TestBusyRefreshBlocksTogglePageEntryRefreshAndActivation(t *testing.T) {
|
||||
t.Fatal("refresh did not start")
|
||||
}
|
||||
|
||||
for _, key := range []string{" ", "p", "?", "r"} {
|
||||
for _, key := range []string{" ", "p", "r"} {
|
||||
updated, blocked := model.Update(keyMessage(key))
|
||||
model = updated.(Model)
|
||||
if blocked != nil || model.page != mainPage || len(module.intents) != 0 {
|
||||
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
|
||||
t.Fatalf("busy refresh accepted %q: page=%v command=%v intents=%#v", key, model.page, blocked != nil, module.intents)
|
||||
}
|
||||
}
|
||||
for _, key := range []tea.KeyMsg{keyMessage("q"), {Type: tea.KeyEsc}, {Type: tea.KeyCtrlC}} {
|
||||
updated, quit := model.Update(key)
|
||||
deferred := updated.(Model)
|
||||
if quit != nil || !deferred.quitRequested || len(module.intents) != 0 {
|
||||
t.Fatalf("busy refresh did not defer quit %q: command=%v requested=%v intents=%#v", key.String(), quit != nil, deferred.quitRequested, module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
model.page = profilesPage
|
||||
updated, blocked := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if blocked != nil || len(module.intents) != 0 {
|
||||
t.Fatalf("busy refresh started activation: command=%v intents=%#v", blocked != nil, module.intents)
|
||||
updated, quit := model.Update(keyMessage("q"))
|
||||
model = updated.(Model)
|
||||
if quit != nil || !model.quitRequested || len(module.intents) != 1 {
|
||||
t.Fatalf("busy refresh did not defer quit: command=%v intents=%#v", quit != nil, module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +266,7 @@ func TestProfileConfirmationQuitKeysCancelWithoutLeavingBusyState(t *testing.T)
|
||||
}
|
||||
|
||||
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition again"}
|
||||
model = updateKey(model, "p")
|
||||
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if command == nil {
|
||||
t.Fatal("confirmation cancellation prevented a later activation")
|
||||
@@ -262,76 +275,6 @@ func TestProfileConfirmationQuitKeysCancelWithoutLeavingBusyState(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleOperationGuardRejectsMismatchedCompletionAndPreventsOverlap(t *testing.T) {
|
||||
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}
|
||||
refreshed := app.Snapshot{ActiveProfile: "fresh", Targets: initial.Targets}
|
||||
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: refreshed}}
|
||||
model := NewModel(module)
|
||||
updated, refreshCommand := model.Update(keyMessage("r"))
|
||||
model = updated.(Model)
|
||||
updated, command := model.Update(keyMessage("q"))
|
||||
model = updated.(Model)
|
||||
if command != nil || !model.quitRequested {
|
||||
t.Fatal("quit was not deferred during refresh")
|
||||
}
|
||||
|
||||
updated, command = model.Update(targetOperationDone{result: app.Result{Snapshot: app.Snapshot{ActiveProfile: "stale"}}})
|
||||
model = updated.(Model)
|
||||
if command != nil || !model.refreshing || !model.busy() || model.snapshot.ActiveProfile != "" {
|
||||
t.Fatal("completion from a non-current operation cleared or corrupted refresh state")
|
||||
}
|
||||
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
if command != nil {
|
||||
t.Fatal("Target operation overlapped the in-flight refresh")
|
||||
}
|
||||
|
||||
refreshCompletion := refreshCommand()
|
||||
updated, command = model.Update(refreshCompletion)
|
||||
model = updated.(Model)
|
||||
if command == nil || !model.busy() || model.snapshot.ActiveProfile != "fresh" {
|
||||
t.Fatal("current refresh completion did not apply fresh state and enter terminal deferred-quit state")
|
||||
}
|
||||
if _, ok := command().(tea.QuitMsg); !ok {
|
||||
t.Fatal("current completion did not return tea.Quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusyQuitWaitsForLongRunningTargetMutationCompletion(t *testing.T) {
|
||||
for _, quitKey := range []tea.KeyMsg{keyMessage("q"), {Type: tea.KeyEsc}, {Type: tea.KeyCtrlC}} {
|
||||
t.Run(quitKey.String(), func(t *testing.T) {
|
||||
initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}
|
||||
completed := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
|
||||
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: completed, Notice: "Shell Target is now Enabled"}}
|
||||
model := NewModel(module)
|
||||
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
model = updated.(Model)
|
||||
if operation == nil || len(module.intents) != 0 {
|
||||
t.Fatal("Target mutation did not start asynchronously")
|
||||
}
|
||||
|
||||
updated, quit := model.Update(quitKey)
|
||||
model = updated.(Model)
|
||||
if quit != nil || !model.quitRequested || len(module.intents) != 0 {
|
||||
t.Fatalf("quit was not deferred before mutation completion: command=%v requested=%v intents=%d", quit != nil, model.quitRequested, len(module.intents))
|
||||
}
|
||||
|
||||
completion := operation()
|
||||
if len(module.intents) != 1 {
|
||||
t.Fatal("deferred quit abandoned the in-flight mutation")
|
||||
}
|
||||
updated, quit = model.Update(completion)
|
||||
model = updated.(Model)
|
||||
if quit == nil || !model.busy() || model.snapshot.Targets[0].State != target.Enabled {
|
||||
t.Fatalf("mutation completion was not consumed before terminal deferred quit: busy=%v snapshot=%#v", model.busy(), model.snapshot)
|
||||
}
|
||||
if _, ok := quit().(tea.QuitMsg); !ok {
|
||||
t.Fatal("completion did not return tea.Quit")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredQuitIsTerminalAfterTargetConfirmationCompletion(t *testing.T) {
|
||||
initial := app.Snapshot{
|
||||
ActiveProfile: "office",
|
||||
@@ -504,6 +447,45 @@ func (m *progressiveTargetModule) DispatchProgress(intent app.Intent) <-chan app
|
||||
m.intents = append(m.intents, intent)
|
||||
return m.updates
|
||||
}
|
||||
func (m *progressiveTargetModule) StartOperation(intent app.Intent) app.OperationStream {
|
||||
m.intents = append(m.intents, intent)
|
||||
updates := make(chan app.OperationUpdate, cap(m.updates)+1)
|
||||
go func() {
|
||||
defer close(updates)
|
||||
for result := range m.updates {
|
||||
phase := app.OperationRunning
|
||||
status := app.OperationStatus{Kind: app.StatusProgress, Summary: result.Notice}
|
||||
var confirmation *app.Confirmation
|
||||
if result.Confirmation != "" {
|
||||
phase = app.OperationAwaitingConfirmation
|
||||
confirmation = &app.Confirmation{Title: "Confirm Active Profile change", Summary: result.Confirmation, ConfirmLabel: "Continue", CancelLabel: "Cancel"}
|
||||
status = app.OperationStatus{Kind: app.StatusConfirmation, Summary: confirmation.Title}
|
||||
} else if result.Complete {
|
||||
phase = app.OperationSucceeded
|
||||
status.Kind = app.StatusSuccess
|
||||
if result.Err != nil {
|
||||
phase = app.OperationFailed
|
||||
status = app.OperationStatus{Kind: app.StatusError, Summary: "Active Profile change failed", Detail: result.Err.Error()}
|
||||
}
|
||||
}
|
||||
progress := append([]app.TargetProgress(nil), result.Progress...)
|
||||
for i := range progress {
|
||||
if progress[i].Applying {
|
||||
progress[i].Phase = app.TargetProgressRunning
|
||||
} else if progress[i].Err != nil {
|
||||
progress[i].Phase = app.TargetProgressFailed
|
||||
} else {
|
||||
progress[i].Phase = app.TargetProgressSucceeded
|
||||
}
|
||||
}
|
||||
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: status, Progress: progress, Confirmation: confirmation, Err: result.Err}
|
||||
if phase == app.OperationAwaitingConfirmation {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return app.OperationStream{Snapshot: m.snapshot, Updates: updates}
|
||||
}
|
||||
|
||||
func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) {
|
||||
initial := app.Snapshot{ActiveProfile: "old", Profiles: []profile.Profile{{Name: "new"}}, Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
|
||||
@@ -572,8 +554,9 @@ func TestTargetFailurePreservesTruthfulStateAndRendersRedactedError(t *testing.T
|
||||
model = updated.(Model)
|
||||
updated, _ = model.Update(command())
|
||||
model = updated.(Model)
|
||||
model = updateKey(model, "e")
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "Enabled") || !strings.Contains(view, "Error:") || !strings.Contains(view, "REDACTED") {
|
||||
if !strings.Contains(view, "Enabled") || !strings.Contains(view, "Current error:") || !strings.Contains(view, "REDACTED") {
|
||||
t.Fatalf("failed operation rendering:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user