778 lines
21 KiB
Go
778 lines
21 KiB
Go
package localcontrol
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"iop/apps/agent/internal/clientprocess"
|
|
"iop/packages/go/agentconfig"
|
|
"iop/packages/go/agentstate"
|
|
iop "iop/proto/gen/iop"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
func TestClientOperationMatrix(t *testing.T) {
|
|
controller := newRecordingClientController()
|
|
operations := newTestClientOperations(t, controller)
|
|
tests := []struct {
|
|
operation string
|
|
kind string
|
|
}{
|
|
{operation: OperationClientStart, kind: "flutter"},
|
|
{operation: OperationClientStart, kind: "unity"},
|
|
{operation: OperationClientStop, kind: "flutter"},
|
|
{operation: OperationClientStop, kind: "unity"},
|
|
{operation: OperationClientFocus, kind: "flutter"},
|
|
}
|
|
expectedCalls := make(map[string]int)
|
|
for index, test := range tests {
|
|
request := clientOperationRequest(
|
|
"message-"+test.operation+"-"+test.kind,
|
|
"command-"+test.operation+"-"+test.kind,
|
|
test.operation,
|
|
test.kind,
|
|
"",
|
|
)
|
|
response := operations.Handle(context.Background(), true, request)
|
|
if response.GetKind() != iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE ||
|
|
!response.GetResponse().GetMutation().GetAccepted() ||
|
|
response.GetResponse().GetMutation().GetSubjectId() != test.kind {
|
|
t.Fatalf("case %d response = %#v", index, response)
|
|
}
|
|
expectedCalls[test.operation]++
|
|
if calls := controller.callsFor(test.operation); calls != expectedCalls[test.operation] {
|
|
t.Fatalf(
|
|
"%s calls = %d, want %d",
|
|
test.operation,
|
|
calls,
|
|
expectedCalls[test.operation],
|
|
)
|
|
}
|
|
}
|
|
|
|
replayRequest := clientOperationRequest(
|
|
"message-replay-first",
|
|
"command-replay",
|
|
OperationClientStart,
|
|
"flutter",
|
|
"",
|
|
)
|
|
first := operations.Handle(context.Background(), true, replayRequest)
|
|
replayed := cloneEnvelope(replayRequest)
|
|
replayed.MessageId = "message-replay-second"
|
|
second := operations.Handle(context.Background(), true, replayed)
|
|
if !proto.Equal(first, second) {
|
|
t.Fatalf("idempotent replay changed response:\nfirst=%v\nsecond=%v", first, second)
|
|
}
|
|
if calls := controller.callsFor(OperationClientStart); calls != 3 {
|
|
t.Fatalf("client.start total calls = %d, want 3", calls)
|
|
}
|
|
}
|
|
|
|
func TestUnityDetailStartsOrFocusesFlutter(t *testing.T) {
|
|
controller := newRecordingClientController()
|
|
operations := newTestClientOperations(t, controller)
|
|
first := operations.Handle(
|
|
context.Background(),
|
|
true,
|
|
clientOperationRequest(
|
|
"detail-start-message",
|
|
"detail-start-command",
|
|
OperationClientDetail,
|
|
"unity",
|
|
"open-run-detail",
|
|
),
|
|
)
|
|
if mutation := first.GetResponse().GetMutation(); mutation.GetSubjectId() != "flutter" ||
|
|
mutation.GetSummary() != "start" {
|
|
t.Fatalf("first detail response = %#v", first)
|
|
}
|
|
second := operations.Handle(
|
|
context.Background(),
|
|
true,
|
|
clientOperationRequest(
|
|
"detail-focus-message",
|
|
"detail-focus-command",
|
|
OperationClientDetail,
|
|
"unity",
|
|
"open-run-detail",
|
|
),
|
|
)
|
|
if mutation := second.GetResponse().GetMutation(); mutation.GetSubjectId() != "flutter" ||
|
|
mutation.GetSummary() != "focus" {
|
|
t.Fatalf("second detail response = %#v", second)
|
|
}
|
|
if calls := controller.callsFor(OperationClientDetail); calls != 2 {
|
|
t.Fatalf("detail calls = %d, want 2", calls)
|
|
}
|
|
if calls := controller.callsFor(OperationClientStart); calls != 0 {
|
|
t.Fatalf("direct Flutter start port calls = %d, want 0", calls)
|
|
}
|
|
}
|
|
|
|
func TestRejectedClientCommandHasZeroProcessCalls(t *testing.T) {
|
|
controller := newRecordingClientController()
|
|
operations := newTestClientOperations(t, controller)
|
|
tests := []struct {
|
|
name string
|
|
authorized bool
|
|
request *iop.AgentLocalEnvelope
|
|
code string
|
|
}{
|
|
{
|
|
name: "unauthorized",
|
|
authorized: false,
|
|
request: clientOperationRequest(
|
|
"denied-message",
|
|
"denied-command",
|
|
OperationClientStart,
|
|
"flutter",
|
|
"",
|
|
),
|
|
code: ErrorPermissionDenied,
|
|
},
|
|
{
|
|
name: "unknown kind",
|
|
authorized: true,
|
|
request: clientOperationRequest(
|
|
"unknown-message",
|
|
"unknown-command",
|
|
OperationClientStart,
|
|
"electron",
|
|
"",
|
|
),
|
|
code: ErrorMalformedFrame,
|
|
},
|
|
{
|
|
name: "unity focus bypass",
|
|
authorized: true,
|
|
request: clientOperationRequest(
|
|
"unity-focus-message",
|
|
"unity-focus-command",
|
|
OperationClientFocus,
|
|
"unity",
|
|
"",
|
|
),
|
|
code: ErrorMalformedFrame,
|
|
},
|
|
{
|
|
name: "detail without capability",
|
|
authorized: true,
|
|
request: clientOperationRequest(
|
|
"detail-message",
|
|
"detail-command",
|
|
OperationClientDetail,
|
|
"unity",
|
|
"",
|
|
),
|
|
code: ErrorMalformedFrame,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
response := operations.Handle(
|
|
context.Background(),
|
|
test.authorized,
|
|
test.request,
|
|
)
|
|
if response.GetError().GetCode() != test.code {
|
|
t.Fatalf(
|
|
"%s error = %q, want %q",
|
|
test.name,
|
|
response.GetError().GetCode(),
|
|
test.code,
|
|
)
|
|
}
|
|
}
|
|
if calls := controller.totalCalls(); calls != 0 {
|
|
t.Fatalf("rejected process calls = %d, want 0", calls)
|
|
}
|
|
}
|
|
|
|
func TestClientCommandIDConflictHasZeroAdditionalProcessCalls(t *testing.T) {
|
|
controller := newRecordingClientController()
|
|
operations := newTestClientOperations(t, controller)
|
|
first := clientOperationRequest(
|
|
"first-message",
|
|
"same-command",
|
|
OperationClientStart,
|
|
"flutter",
|
|
"",
|
|
)
|
|
if response := operations.Handle(
|
|
context.Background(),
|
|
true,
|
|
first,
|
|
); response.GetError() != nil {
|
|
t.Fatalf("first response = %#v", response)
|
|
}
|
|
conflict := clientOperationRequest(
|
|
"conflict-message",
|
|
"same-command",
|
|
OperationClientStart,
|
|
"unity",
|
|
"",
|
|
)
|
|
response := operations.Handle(context.Background(), true, conflict)
|
|
if response.GetError().GetCode() != ErrorCommandIDConflict {
|
|
t.Fatalf("conflict response = %#v", response)
|
|
}
|
|
if calls := controller.totalCalls(); calls != 1 {
|
|
t.Fatalf("conflict process calls = %d, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func TestAcceptedIncompleteClientCommandReusesCompletedManagerReceipt(t *testing.T) {
|
|
statePath := filepath.Join(t.TempDir(), "state.json")
|
|
store, err := agentstate.NewStore(statePath)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
backend := &countingProcessBackend{}
|
|
manager, err := clientprocess.NewManager(
|
|
context.Background(),
|
|
map[string]agentconfig.ClientProcessSpec{
|
|
string(clientprocess.ClientFlutter): {
|
|
Executable: "/bin/false",
|
|
WorkingDirectory: "/tmp",
|
|
FocusArgs: []string{"focus"},
|
|
},
|
|
},
|
|
store,
|
|
clientprocess.WithProcessBackend(backend),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewManager: %v", err)
|
|
}
|
|
defer manager.Close(context.Background())
|
|
|
|
// Start Flutter first so it's live
|
|
if _, err := manager.Start(context.Background(), clientprocess.ClientFlutter, "seed-start"); err != nil {
|
|
t.Fatalf("Start flutter: %v", err)
|
|
}
|
|
initialFocusCalls := backend.focusCalls
|
|
|
|
ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16)
|
|
if err != nil {
|
|
t.Fatalf("NewLedger: %v", err)
|
|
}
|
|
operations, err := NewClientOperations(manager, ledger)
|
|
if err != nil {
|
|
t.Fatalf("NewClientOperations: %v", err)
|
|
}
|
|
|
|
req := clientOperationRequest("msg-1", "cmd-focus-1", OperationClientFocus, "flutter", "")
|
|
res1 := operations.Handle(context.Background(), true, req)
|
|
if res1.GetError() != nil {
|
|
t.Fatalf("first Handle error: %v", res1.GetError())
|
|
}
|
|
if backend.focusCalls != initialFocusCalls+1 {
|
|
t.Fatalf("focus calls = %d, want %d", backend.focusCalls, initialFocusCalls+1)
|
|
}
|
|
|
|
// Simulate ledger restart where command was accepted but not finished in ledger.
|
|
// Recreating ledger reads existing store state where Manager stored completed receipt.
|
|
ledger2, err := NewLedger(context.Background(), store, "daemon-ops", 16)
|
|
if err != nil {
|
|
t.Fatalf("NewLedger 2: %v", err)
|
|
}
|
|
operations2, err := NewClientOperations(manager, ledger2)
|
|
if err != nil {
|
|
t.Fatalf("NewClientOperations 2: %v", err)
|
|
}
|
|
|
|
reqReplay := clientOperationRequest("msg-2", "cmd-focus-1", OperationClientFocus, "flutter", "")
|
|
res2 := operations2.Handle(context.Background(), true, reqReplay)
|
|
if res2.GetError() != nil {
|
|
t.Fatalf("replay Handle error: %v", res2.GetError())
|
|
}
|
|
if backend.focusCalls != initialFocusCalls+1 {
|
|
t.Fatalf("focus calls after replay = %d, want %d (zero additional calls)", backend.focusCalls, initialFocusCalls+1)
|
|
}
|
|
}
|
|
|
|
func TestAcceptedIncompleteClientCommandReusesExactManagerReceiptAfterStateChange(t *testing.T) {
|
|
base, err := agentstate.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
store := &ledgerFinishFailingStore{Store: base, failKey: "local-control/ledger"}
|
|
|
|
backend := &countingProcessBackend{}
|
|
manager, err := clientprocess.NewManager(
|
|
context.Background(),
|
|
map[string]agentconfig.ClientProcessSpec{
|
|
string(clientprocess.ClientFlutter): {
|
|
Executable: "/bin/false",
|
|
WorkingDirectory: "/tmp",
|
|
FocusArgs: []string{"focus"},
|
|
},
|
|
},
|
|
store,
|
|
clientprocess.WithProcessBackend(backend),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewManager: %v", err)
|
|
}
|
|
defer manager.Close(context.Background())
|
|
|
|
// Live, connected Flutter so focus and a later disconnect are valid.
|
|
if _, err := manager.Start(context.Background(), clientprocess.ClientFlutter, "seed-start"); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if _, err := manager.SetConnected(context.Background(), clientprocess.ClientFlutter, true); err != nil {
|
|
t.Fatalf("SetConnected true: %v", err)
|
|
}
|
|
|
|
ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16)
|
|
if err != nil {
|
|
t.Fatalf("NewLedger: %v", err)
|
|
}
|
|
operations, err := NewClientOperations(manager, ledger)
|
|
if err != nil {
|
|
t.Fatalf("NewClientOperations: %v", err)
|
|
}
|
|
|
|
// Fail only the first final ledger CAS so the command is durably accepted
|
|
// but incomplete while the manager retains the completed focus receipt.
|
|
store.armFinishFailure()
|
|
first := operations.Handle(
|
|
context.Background(),
|
|
true,
|
|
clientOperationRequest("msg-1", "cmd-focus-x", OperationClientFocus, "flutter", ""),
|
|
)
|
|
if first.GetError() == nil {
|
|
t.Fatalf("expected injected finish failure to surface, got %#v", first)
|
|
}
|
|
if backend.focusCalls != 1 {
|
|
t.Fatalf("focus calls after first handle = %d, want 1", backend.focusCalls)
|
|
}
|
|
|
|
// Intervening state change: disconnect so the current slot projection
|
|
// (starting) differs from the immutable focus snapshot (connected).
|
|
if _, err := manager.SetConnected(context.Background(), clientprocess.ClientFlutter, false); err != nil {
|
|
t.Fatalf("SetConnected false: %v", err)
|
|
}
|
|
|
|
// Recover through a new ledger instance; the accepted-incomplete command
|
|
// re-dispatches into manager receipt recovery.
|
|
ledger2, err := NewLedger(context.Background(), store, "daemon-ops", 16)
|
|
if err != nil {
|
|
t.Fatalf("NewLedger 2: %v", err)
|
|
}
|
|
operations2, err := NewClientOperations(manager, ledger2)
|
|
if err != nil {
|
|
t.Fatalf("NewClientOperations 2: %v", err)
|
|
}
|
|
second := operations2.Handle(
|
|
context.Background(),
|
|
true,
|
|
clientOperationRequest("msg-2", "cmd-focus-x", OperationClientFocus, "flutter", ""),
|
|
)
|
|
if second.GetError() != nil {
|
|
t.Fatalf("replay error: %v", second.GetError())
|
|
}
|
|
if backend.focusCalls != 1 {
|
|
t.Fatalf("focus calls after replay = %d, want 1 (zero additional mutation)", backend.focusCalls)
|
|
}
|
|
mutation := second.GetResponse().GetMutation()
|
|
if mutation.GetSubjectId() != "flutter" || mutation.GetSummary() != "focus" {
|
|
t.Fatalf("replay mutation = %#v", mutation)
|
|
}
|
|
// Exact original result: the state reflects the connected snapshot captured
|
|
// at completion, not the current disconnected projection.
|
|
if mutation.GetState() != string(clientprocess.StateConnected) {
|
|
t.Fatalf(
|
|
"replay state = %q, want %q (immutable receipt snapshot)",
|
|
mutation.GetState(),
|
|
clientprocess.StateConnected,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ledgerFinishFailingStore fails exactly one ledger CompareAndSwap once armed.
|
|
// The first ledger CAS after arming is the BeginCommand acceptance; the second
|
|
// is the FinishCommand completion, which is the one this store rejects.
|
|
type ledgerFinishFailingStore struct {
|
|
*agentstate.Store
|
|
failKey string
|
|
mu sync.Mutex
|
|
armed bool
|
|
count int
|
|
}
|
|
|
|
func (s *ledgerFinishFailingStore) armFinishFailure() {
|
|
s.mu.Lock()
|
|
s.armed = true
|
|
s.count = 0
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *ledgerFinishFailingStore) CompareAndSwapIntegrationRecord(
|
|
ctx context.Context,
|
|
key string,
|
|
revision string,
|
|
payload []byte,
|
|
) (string, error) {
|
|
s.mu.Lock()
|
|
fail := false
|
|
if s.armed && key == s.failKey {
|
|
s.count++
|
|
if s.count == 2 {
|
|
fail = true
|
|
s.armed = false
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
if fail {
|
|
return "", errors.New("injected finish CAS failure")
|
|
}
|
|
return s.Store.CompareAndSwapIntegrationRecord(ctx, key, revision, payload)
|
|
}
|
|
|
|
func TestAcceptedIncompleteClientFocusFailsClosedWhenOutcomePending(t *testing.T) {
|
|
statePath := filepath.Join(t.TempDir(), "state.json")
|
|
store, err := agentstate.NewStore(statePath)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
|
|
// Seed store with a live client and a pending focus receipt for cmd-pending
|
|
identity := clientprocess.ProcessIdentity{PID: 500, StartToken: "fake-token"}
|
|
rec := clientprocess.Record{
|
|
SchemaVersion: clientprocess.RecordSchemaVersion,
|
|
Kind: clientprocess.ClientFlutter,
|
|
State: clientprocess.StateConnected,
|
|
Connected: true,
|
|
Identity: &identity,
|
|
Commands: map[string]clientprocess.CommandReceipt{
|
|
"cmd-pending": {
|
|
CommandID: "cmd-pending",
|
|
Action: "focus",
|
|
Status: clientprocess.CommandReceiptPending,
|
|
},
|
|
},
|
|
}
|
|
// Write record manually via raw store integration record
|
|
payload, err := jsonMarshalRecord(rec)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if _, err := store.CompareAndSwapIntegrationRecord(
|
|
context.Background(),
|
|
"client-process/flutter",
|
|
"",
|
|
payload,
|
|
); err != nil {
|
|
t.Fatalf("seed store: %v", err)
|
|
}
|
|
|
|
backend := &countingProcessBackend{liveIdentity: identity}
|
|
manager, err := clientprocess.NewManager(
|
|
context.Background(),
|
|
map[string]agentconfig.ClientProcessSpec{
|
|
string(clientprocess.ClientFlutter): {
|
|
Executable: "/bin/false",
|
|
WorkingDirectory: "/tmp",
|
|
FocusArgs: []string{"focus"},
|
|
},
|
|
},
|
|
store,
|
|
clientprocess.WithProcessBackend(backend),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewManager: %v", err)
|
|
}
|
|
defer manager.Close(context.Background())
|
|
|
|
ledger, err := NewLedger(context.Background(), store, "daemon-ops", 16)
|
|
if err != nil {
|
|
t.Fatalf("NewLedger: %v", err)
|
|
}
|
|
operations, err := NewClientOperations(manager, ledger)
|
|
if err != nil {
|
|
t.Fatalf("NewClientOperations: %v", err)
|
|
}
|
|
|
|
req := clientOperationRequest("msg-p", "cmd-pending", OperationClientFocus, "flutter", "")
|
|
res := operations.Handle(context.Background(), true, req)
|
|
if res.GetError() == nil {
|
|
t.Fatalf("expected error for pending outcome, got success")
|
|
}
|
|
if res.GetError().GetCode() != ErrorInvalidState || !res.GetError().GetRetryable() {
|
|
t.Fatalf("error = %#v, want retryable ErrorInvalidState", res.GetError())
|
|
}
|
|
if backend.focusCalls != 0 {
|
|
t.Fatalf("backend focus calls = %d, want 0", backend.focusCalls)
|
|
}
|
|
}
|
|
|
|
type countingProcessBackend struct {
|
|
mu sync.Mutex
|
|
focusCalls int
|
|
liveIdentity clientprocess.ProcessIdentity
|
|
processes map[clientprocess.ProcessIdentity]*countingOwnedProcess
|
|
}
|
|
|
|
func (b *countingProcessBackend) Start(
|
|
context.Context,
|
|
clientprocess.ClientKind,
|
|
agentconfig.ClientProcessSpec,
|
|
) (clientprocess.OwnedProcess, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.processes == nil {
|
|
b.processes = make(map[clientprocess.ProcessIdentity]*countingOwnedProcess)
|
|
}
|
|
id := clientprocess.ProcessIdentity{PID: 300 + len(b.processes), StartToken: "fake"}
|
|
proc := &countingOwnedProcess{
|
|
identity: id,
|
|
done: make(chan struct{}),
|
|
}
|
|
b.processes[id] = proc
|
|
return proc, nil
|
|
}
|
|
|
|
type countingOwnedProcess struct {
|
|
identity clientprocess.ProcessIdentity
|
|
done chan struct{}
|
|
stopOnce sync.Once
|
|
}
|
|
|
|
func (p *countingOwnedProcess) stop() {
|
|
p.stopOnce.Do(func() {
|
|
close(p.done)
|
|
})
|
|
}
|
|
|
|
func (p *countingOwnedProcess) Identity() clientprocess.ProcessIdentity {
|
|
return p.identity
|
|
}
|
|
|
|
func (p *countingOwnedProcess) Wait() error {
|
|
<-p.done
|
|
return nil
|
|
}
|
|
|
|
func (p *countingOwnedProcess) Abort() error {
|
|
p.stop()
|
|
return nil
|
|
}
|
|
|
|
func (b *countingProcessBackend) Inspect(
|
|
_ context.Context,
|
|
id clientprocess.ProcessIdentity,
|
|
) (clientprocess.IdentityObservation, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if proc, ok := b.processes[id]; ok {
|
|
select {
|
|
case <-proc.done:
|
|
return clientprocess.IdentityObservation{State: clientprocess.IdentityExited}, nil
|
|
default:
|
|
return clientprocess.IdentityObservation{State: clientprocess.IdentityLive, Identity: id}, nil
|
|
}
|
|
}
|
|
return clientprocess.IdentityObservation{
|
|
State: clientprocess.IdentityLive,
|
|
Identity: id,
|
|
}, nil
|
|
}
|
|
|
|
func (b *countingProcessBackend) Signal(
|
|
_ context.Context,
|
|
id clientprocess.ProcessIdentity,
|
|
_ os.Signal,
|
|
) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if proc, ok := b.processes[id]; ok {
|
|
proc.stop()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *countingProcessBackend) Kill(
|
|
_ context.Context,
|
|
id clientprocess.ProcessIdentity,
|
|
) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if proc, ok := b.processes[id]; ok {
|
|
proc.stop()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *countingProcessBackend) Focus(
|
|
context.Context,
|
|
clientprocess.ClientKind,
|
|
agentconfig.ClientProcessSpec,
|
|
) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.focusCalls++
|
|
return nil
|
|
}
|
|
|
|
func jsonMarshalRecord(rec clientprocess.Record) ([]byte, error) {
|
|
rec.UpdatedAt = time.Now().UTC()
|
|
return json.Marshal(rec)
|
|
}
|
|
|
|
type recordingClientController struct {
|
|
mu sync.Mutex
|
|
calls map[string]int
|
|
flutter bool
|
|
}
|
|
|
|
func newRecordingClientController() *recordingClientController {
|
|
return &recordingClientController{calls: make(map[string]int)}
|
|
}
|
|
|
|
func (controller *recordingClientController) Start(
|
|
_ context.Context,
|
|
kind clientprocess.ClientKind,
|
|
_ string,
|
|
) (clientprocess.Result, error) {
|
|
controller.mu.Lock()
|
|
defer controller.mu.Unlock()
|
|
controller.calls[OperationClientStart]++
|
|
if kind == clientprocess.ClientFlutter {
|
|
controller.flutter = true
|
|
}
|
|
return clientResult(kind, "start"), nil
|
|
}
|
|
|
|
func (controller *recordingClientController) Stop(
|
|
_ context.Context,
|
|
kind clientprocess.ClientKind,
|
|
_ string,
|
|
) (clientprocess.Result, error) {
|
|
controller.mu.Lock()
|
|
defer controller.mu.Unlock()
|
|
controller.calls[OperationClientStop]++
|
|
if kind == clientprocess.ClientFlutter {
|
|
controller.flutter = false
|
|
}
|
|
result := clientResult(kind, "stop")
|
|
result.Record.State = clientprocess.StateStopped
|
|
return result, nil
|
|
}
|
|
|
|
func (controller *recordingClientController) Focus(
|
|
_ context.Context,
|
|
kind clientprocess.ClientKind,
|
|
_ string,
|
|
) (clientprocess.Result, error) {
|
|
controller.mu.Lock()
|
|
defer controller.mu.Unlock()
|
|
controller.calls[OperationClientFocus]++
|
|
return clientResult(kind, "focus"), nil
|
|
}
|
|
|
|
func (controller *recordingClientController) StartOrFocusFlutter(
|
|
_ context.Context,
|
|
_ string,
|
|
) (clientprocess.Result, error) {
|
|
controller.mu.Lock()
|
|
defer controller.mu.Unlock()
|
|
controller.calls[OperationClientDetail]++
|
|
action := "focus"
|
|
if !controller.flutter {
|
|
action = "start"
|
|
controller.flutter = true
|
|
}
|
|
return clientResult(clientprocess.ClientFlutter, action), nil
|
|
}
|
|
|
|
func (controller *recordingClientController) callsFor(operation string) int {
|
|
controller.mu.Lock()
|
|
defer controller.mu.Unlock()
|
|
return controller.calls[operation]
|
|
}
|
|
|
|
func (controller *recordingClientController) totalCalls() int {
|
|
controller.mu.Lock()
|
|
defer controller.mu.Unlock()
|
|
total := 0
|
|
for _, calls := range controller.calls {
|
|
total += calls
|
|
}
|
|
return total
|
|
}
|
|
|
|
func clientResult(
|
|
kind clientprocess.ClientKind,
|
|
action string,
|
|
) clientprocess.Result {
|
|
return clientprocess.Result{
|
|
Record: clientprocess.Record{
|
|
SchemaVersion: clientprocess.RecordSchemaVersion,
|
|
Kind: kind,
|
|
State: clientprocess.StateConnected,
|
|
Connected: true,
|
|
},
|
|
Changed: true,
|
|
Action: action,
|
|
}
|
|
}
|
|
|
|
func newTestClientOperations(
|
|
t *testing.T,
|
|
controller ClientProcessController,
|
|
) *ClientOperations {
|
|
t.Helper()
|
|
store, err := agentstate.NewStore(
|
|
filepath.Join(t.TempDir(), "state.json"),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
ledger, err := NewLedger(
|
|
context.Background(),
|
|
store,
|
|
"daemon-client-operations",
|
|
16,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewLedger: %v", err)
|
|
}
|
|
operations, err := NewClientOperations(controller, ledger)
|
|
if err != nil {
|
|
t.Fatalf("NewClientOperations: %v", err)
|
|
}
|
|
return operations
|
|
}
|
|
|
|
func clientOperationRequest(
|
|
messageID string,
|
|
commandID string,
|
|
operation string,
|
|
kind string,
|
|
capability string,
|
|
) *iop.AgentLocalEnvelope {
|
|
return &iop.AgentLocalEnvelope{
|
|
ProtocolVersion: ProtocolVersion,
|
|
Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_REQUEST,
|
|
MessageId: messageID,
|
|
Operation: operation,
|
|
Payload: &iop.AgentLocalEnvelope_Request{
|
|
Request: &iop.AgentLocalRequest{
|
|
CommandId: commandID,
|
|
Payload: &iop.AgentLocalRequest_Client{
|
|
Client: &iop.AgentLocalClientRequest{
|
|
ClientKind: kind,
|
|
Capability: capability,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|