feat: unify interactive operation coordination
This commit is contained in:
@@ -5,7 +5,9 @@ import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
@@ -13,12 +15,17 @@ import (
|
||||
)
|
||||
|
||||
type coordinatedOperationTarget struct {
|
||||
id string
|
||||
state target.State
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
confirmation string
|
||||
failure error
|
||||
id string
|
||||
state target.State
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
detectionStarted chan struct{}
|
||||
detectionRelease chan struct{}
|
||||
confirmation string
|
||||
failure error
|
||||
mu sync.Mutex
|
||||
inDetection bool
|
||||
overlapObserved chan bool
|
||||
}
|
||||
|
||||
func (t *coordinatedOperationTarget) ID() string { return t.id }
|
||||
@@ -32,7 +39,28 @@ func (t *coordinatedOperationTarget) Inspect(context.Context, *profile.Profile)
|
||||
return target.Inspection{State: t.state, Evidence: "fresh state"}
|
||||
}
|
||||
func (t *coordinatedOperationTarget) Confirmation(bool) string { return t.confirmation }
|
||||
func (t *coordinatedOperationTarget) Detect(ctx context.Context, active *profile.Profile) target.Detection {
|
||||
t.mu.Lock()
|
||||
t.inDetection = true
|
||||
t.mu.Unlock()
|
||||
if t.detectionStarted != nil {
|
||||
close(t.detectionStarted)
|
||||
}
|
||||
if t.detectionRelease != nil {
|
||||
<-t.detectionRelease
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.inDetection = false
|
||||
t.mu.Unlock()
|
||||
return target.Detection{Availability: t.Availability(ctx), Inspection: t.Inspect(ctx, active)}
|
||||
}
|
||||
func (t *coordinatedOperationTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult {
|
||||
t.mu.Lock()
|
||||
overlapped := t.inDetection
|
||||
t.mu.Unlock()
|
||||
if t.overlapObserved != nil {
|
||||
t.overlapObserved <- overlapped
|
||||
}
|
||||
if t.started != nil {
|
||||
close(t.started)
|
||||
}
|
||||
@@ -66,14 +94,15 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
|
||||
|
||||
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
|
||||
running := <-operation.Updates
|
||||
if running.Phase != app.OperationRunning || len(running.Progress) != 1 || running.Progress[0].Phase != app.TargetProgressRunning || running.Progress[0].TargetID != "shell" || running.Status.Kind != app.StatusProgress {
|
||||
runningProgress := running.Progress()
|
||||
if !running.IsRunning() || len(runningProgress) != 1 || runningProgress[0].Phase != app.TargetProgressRunning || runningProgress[0].TargetID != "shell" || running.Status().Kind != app.StatusProgress {
|
||||
t.Fatalf("running update = %#v", running)
|
||||
}
|
||||
<-adapter.started
|
||||
|
||||
blocked := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: false})
|
||||
blockedUpdate := <-blocked.Updates
|
||||
if blockedUpdate.Phase != app.OperationBlocked || blockedUpdate.Status.Kind != app.StatusBlocked || !strings.Contains(blockedUpdate.Status.Summary, "already in progress") {
|
||||
if !blockedUpdate.IsTerminal() || blockedUpdate.Status().Kind != app.StatusBlocked || !strings.Contains(blockedUpdate.Status().Summary, "already in progress") {
|
||||
t.Fatalf("blocked update = %#v", blockedUpdate)
|
||||
}
|
||||
if _, open := <-blocked.Updates; open {
|
||||
@@ -82,7 +111,8 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
|
||||
|
||||
close(adapter.release)
|
||||
final := <-operation.Updates
|
||||
if final.Phase != app.OperationSucceeded || final.Snapshot.Targets[0].State != target.Enabled || len(final.Progress) != 1 || final.Progress[0].Phase != app.TargetProgressSucceeded {
|
||||
finalProgress := final.Progress()
|
||||
if !final.IsSucceeded() || final.Snapshot.Targets[0].State != target.Enabled || len(finalProgress) != 1 || finalProgress[0].Phase != app.TargetProgressSucceeded {
|
||||
t.Fatalf("final update = %#v", final)
|
||||
}
|
||||
if _, open := <-operation.Updates; open {
|
||||
@@ -90,6 +120,86 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectionOperationSerializesWithSynchronousMutation(t *testing.T) {
|
||||
adapter := &coordinatedOperationTarget{
|
||||
id: "shell", state: target.Disabled,
|
||||
detectionStarted: make(chan struct{}), detectionRelease: make(chan struct{}),
|
||||
started: make(chan struct{}), overlapObserved: make(chan bool, 1),
|
||||
}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "office", SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
|
||||
detection := application.StartOperation(app.Intent{Kind: app.RefreshTargets})
|
||||
<-adapter.detectionStarted
|
||||
mutationAttempted := make(chan struct{})
|
||||
mutationDone := make(chan app.Result, 1)
|
||||
go func() {
|
||||
close(mutationAttempted)
|
||||
mutationDone <- application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
|
||||
}()
|
||||
<-mutationAttempted
|
||||
close(adapter.detectionRelease)
|
||||
for range detection.Updates {
|
||||
}
|
||||
result := <-mutationDone
|
||||
if result.Err != nil || result.Snapshot.Targets[0].State != target.Enabled {
|
||||
t.Fatalf("serialized mutation = %#v", result)
|
||||
}
|
||||
if overlapped := <-adapter.overlapObserved; overlapped {
|
||||
t.Fatal("mutation overlapped Target detection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartOperationReturnsBeforeDispatchSerializationIsAvailable(t *testing.T) {
|
||||
adapter := &coordinatedOperationTarget{
|
||||
id: "shell", state: target.Disabled,
|
||||
started: make(chan struct{}), release: make(chan struct{}),
|
||||
detectionStarted: make(chan struct{}), detectionRelease: make(chan struct{}),
|
||||
}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "office", SingleURL: "http://proxy.example:8080"}}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
|
||||
mutationDone := make(chan app.Result, 1)
|
||||
go func() {
|
||||
mutationDone <- application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true})
|
||||
}()
|
||||
<-adapter.started // barrier: synchronous Dispatch owns dispatch serialization
|
||||
|
||||
returned := make(chan app.OperationStream, 1)
|
||||
go func() { returned <- application.StartOperation(app.Intent{Kind: app.RefreshTargets}) }()
|
||||
var stream app.OperationStream
|
||||
select {
|
||||
case stream = <-returned:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("StartOperation blocked Bubble Tea while dispatch serialization was held")
|
||||
}
|
||||
|
||||
close(adapter.release)
|
||||
if result := <-mutationDone; result.Err != nil {
|
||||
t.Fatal(result.Err)
|
||||
}
|
||||
<-adapter.detectionStarted
|
||||
close(adapter.detectionRelease)
|
||||
for range stream.Updates {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveOperationStreamUsesStructuredConfirmation(t *testing.T) {
|
||||
adapter := &coordinatedOperationTarget{id: "docker-daemon", state: target.Disabled, confirmation: "Docker will restart and may disrupt running work."}
|
||||
application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter})
|
||||
@@ -105,7 +215,8 @@ func TestInteractiveOperationStreamUsesStructuredConfirmation(t *testing.T) {
|
||||
|
||||
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true})
|
||||
update := <-operation.Updates
|
||||
if update.Phase != app.OperationAwaitingConfirmation || update.Confirmation == nil || update.Confirmation.Title == "" || update.Confirmation.CancelLabel == "" || update.Status.Kind != app.StatusConfirmation {
|
||||
confirmation := update.Confirmation()
|
||||
if !update.IsConfirmation() || confirmation == nil || confirmation.Title == "" || confirmation.CancelLabel == "" {
|
||||
t.Fatalf("confirmation update = %#v", update)
|
||||
}
|
||||
if _, open := <-operation.Updates; open {
|
||||
@@ -140,7 +251,7 @@ func TestInteractiveProfileTransitionReportsFirstFailureWithoutRollback(t *testi
|
||||
for update := range operation.Updates {
|
||||
final = update
|
||||
}
|
||||
if final.Phase != app.OperationFailed || final.FirstFailedTargetID != "second" || final.Snapshot.ActiveProfile != "new" {
|
||||
if !final.IsFailed() || final.FirstFailedTargetID() != "second" || final.Snapshot.ActiveProfile != "new" {
|
||||
t.Fatalf("final transition = %#v", final)
|
||||
}
|
||||
if final.Snapshot.Targets[0].State != target.Enabled || final.Snapshot.Targets[1].State != target.Drifted {
|
||||
|
||||
Reference in New Issue
Block a user