- Move cli_test.go to internal/, lifecycle/, onshot/, persistent/ - Restructure CLI adapter modules into organized subdirectories
261 lines
6.8 KiB
Go
261 lines
6.8 KiB
Go
package lifecycle_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
clipkg "iop/apps/node/internal/adapters/cli"
|
|
"iop/apps/node/internal/adapters/cli/internal/testutil"
|
|
noderuntime "iop/apps/node/internal/runtime"
|
|
"iop/packages/config"
|
|
)
|
|
|
|
func TestCLIStartPersistentReturnsErrorWhenProcessExitsDuringStartup(t *testing.T) {
|
|
testutil.RequirePTYSupport(t)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"exits-immediately": {
|
|
Command: "sh",
|
|
Args: []string{"-c", "exit 2"},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
StartupIdleTimeoutMS: 50,
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
|
|
if err := c.Start(context.Background()); err == nil {
|
|
t.Fatal("expected error when persistent process exits during startup")
|
|
}
|
|
}
|
|
|
|
func TestCLIStartCleansUpStartedProfilesWhenLaterProfileFails(t *testing.T) {
|
|
testutil.RequireUnixShell(t)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"ok-profile": {
|
|
Command: "sh",
|
|
Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
|
|
Persistent: true,
|
|
Terminal: false,
|
|
StartupIdleTimeoutMS: 50,
|
|
ResponseIdleTimeoutMS: 200,
|
|
},
|
|
"fail-profile": {
|
|
Command: "sh",
|
|
Args: []string{"-c", "exit 2"},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
StartupIdleTimeoutMS: 50,
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
ctx := context.Background()
|
|
|
|
if err := c.Start(ctx); err == nil {
|
|
t.Fatal("expected Start to fail when one persistent profile exits immediately")
|
|
}
|
|
|
|
_ = c.Stop(ctx)
|
|
_ = c.Stop(ctx)
|
|
|
|
for _, model := range []string{"ok-profile", "fail-profile"} {
|
|
sink := &testutil.FakeSink{}
|
|
if err := c.Execute(ctx, noderuntime.ExecutionSpec{
|
|
RunID: "post-cleanup",
|
|
Model: model,
|
|
Input: map[string]any{"prompt": "test"},
|
|
}, sink); err == nil {
|
|
t.Fatalf("expected Execute for %q to fail after cleanup, got nil", model)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCLIStartDeterministicOrder(t *testing.T) {
|
|
testutil.RequireUnixShell(t)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"Z-alive": {
|
|
Command: "sh",
|
|
Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
|
|
Persistent: true,
|
|
Terminal: false,
|
|
StartupIdleTimeoutMS: 50,
|
|
ResponseIdleTimeoutMS: 200,
|
|
},
|
|
"Z-fail": {
|
|
Command: "sh",
|
|
Args: []string{"-c", "exit 2"},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
StartupIdleTimeoutMS: 50,
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
ctx := context.Background()
|
|
|
|
if err := c.Start(ctx); err == nil {
|
|
t.Fatal("expected Start to fail when one persistent profile exits immediately")
|
|
}
|
|
|
|
_ = c.Stop(ctx)
|
|
_ = c.Stop(ctx)
|
|
|
|
for _, model := range []string{"Z-alive", "Z-fail"} {
|
|
sink := &testutil.FakeSink{}
|
|
if err := c.Execute(ctx, noderuntime.ExecutionSpec{
|
|
RunID: "post-cleanup",
|
|
Model: model,
|
|
Input: map[string]any{"prompt": "test"},
|
|
}, sink); err == nil {
|
|
t.Fatalf("expected Execute for %q to fail after cleanup, got nil", model)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCLIConcurrentExecuteAndStop(t *testing.T) {
|
|
testutil.RequirePTYSupport(t)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"slow-echo": {
|
|
Command: "sh",
|
|
Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.05; printf "reply:%s\n" "$line"; done`},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
ResponseIdleTimeoutMS: 50,
|
|
StartupIdleTimeoutMS: 50,
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
|
|
ctx := context.Background()
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
|
|
errCh := make(chan error, 10)
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
for i := 0; i < 3; i++ {
|
|
sink := &testutil.FakeSink{}
|
|
execCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
|
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: fmt.Sprintf("run-conc-%d", i),
|
|
Model: "slow-echo",
|
|
Input: map[string]any{"prompt": fmt.Sprintf("conc-%d", i)},
|
|
}, sink)
|
|
cancel()
|
|
if err != nil {
|
|
errCh <- fmt.Errorf("concurrent execute %d: %w", i, err)
|
|
}
|
|
}
|
|
done <- struct{}{}
|
|
}()
|
|
|
|
go func() {
|
|
time.Sleep(200 * time.Millisecond)
|
|
_ = c.Stop(ctx)
|
|
done <- struct{}{}
|
|
}()
|
|
|
|
go func() {
|
|
time.Sleep(300 * time.Millisecond)
|
|
for i := 0; i < 2; i++ {
|
|
sink := &testutil.FakeSink{}
|
|
execCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
|
|
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
|
RunID: fmt.Sprintf("run-post-stop-%d", i),
|
|
Model: "slow-echo",
|
|
Input: map[string]any{"prompt": fmt.Sprintf("post-stop-%d", i)},
|
|
}, sink)
|
|
cancel()
|
|
if err != nil {
|
|
errCh <- fmt.Errorf("post-stop execute %d: %w", i, err)
|
|
}
|
|
}
|
|
done <- struct{}{}
|
|
}()
|
|
|
|
timeout := time.After(10 * time.Second)
|
|
for completed := 0; completed < 3; completed++ {
|
|
select {
|
|
case <-done:
|
|
case <-timeout:
|
|
t.Fatal("TestCLIConcurrentExecuteAndStop timed out; possible deadlock")
|
|
}
|
|
}
|
|
close(errCh)
|
|
for range errCh {
|
|
}
|
|
}
|
|
|
|
func TestCLIStartPartialRollbackWithMarkers(t *testing.T) {
|
|
testutil.RequireUnixShell(t)
|
|
|
|
markerDir := t.TempDir()
|
|
startMarker := "START_MARKER_A"
|
|
startMarkerPath := filepath.Join(markerDir, "marker_a")
|
|
profileACmd := fmt.Sprintf(`mkdir -p %s; echo %s > %s/marker_a; while IFS= read -r line; do printf "reply:%%s\n" "$line"; done`,
|
|
markerDir, startMarker, markerDir)
|
|
|
|
cfg := config.CLIConf{
|
|
Enabled: true,
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"profile-a": {
|
|
Command: "sh",
|
|
Args: []string{"-c", profileACmd},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
StartupIdleTimeoutMS: 50,
|
|
ResponseIdleTimeoutMS: 200,
|
|
},
|
|
"profile-b": {
|
|
Command: "sh",
|
|
Args: []string{"-c", "exit 2"},
|
|
Persistent: true,
|
|
Terminal: true,
|
|
StartupIdleTimeoutMS: 50,
|
|
},
|
|
},
|
|
}
|
|
c := clipkg.New(cfg, zap.NewNop())
|
|
ctx := context.Background()
|
|
|
|
if err := c.Start(ctx); err == nil {
|
|
t.Fatal("expected Start to fail when profile-b exits immediately")
|
|
}
|
|
|
|
_ = c.Stop(ctx)
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
testutil.RequireEventually(t, 2*time.Second, 50*time.Millisecond, func() bool {
|
|
return testutil.ReadMarker(startMarkerPath) == startMarker
|
|
})
|
|
|
|
sink := &testutil.FakeSink{}
|
|
if err := c.Execute(ctx, noderuntime.ExecutionSpec{
|
|
RunID: "post-rollback",
|
|
Model: "profile-a",
|
|
Input: map[string]any{"prompt": "test"},
|
|
}, sink); err == nil {
|
|
t.Fatal("expected Execute for profile-a to fail with no-session after rollback")
|
|
}
|
|
}
|