You've already forked agentic-coding-workflow
164 lines
4.5 KiB
Go
164 lines
4.5 KiB
Go
package run
|
|
|
|
import (
|
|
"context"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// fakeRepo 仅实现 supervisor 实际用到的 UpdateRunProfileExit;其余满足接口即可。
|
|
type fakeRepo struct {
|
|
mu sync.Mutex
|
|
exitID uuid.UUID
|
|
exitCode *int32
|
|
lastErr string
|
|
called bool
|
|
}
|
|
|
|
func (f *fakeRepo) CreateRunProfile(context.Context, *RunProfile) (*RunProfile, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeRepo) GetRunProfileByID(context.Context, uuid.UUID) (*RunProfile, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeRepo) ListRunProfilesByWorkspace(context.Context, uuid.UUID) ([]*RunProfile, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeRepo) UpdateRunProfile(context.Context, *RunProfile) (*RunProfile, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeRepo) DeleteRunProfile(context.Context, uuid.UUID) error { return nil }
|
|
func (f *fakeRepo) MarkRunProfileStarted(context.Context, uuid.UUID) (*RunProfile, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeRepo) UpdateRunProfileExit(_ context.Context, id uuid.UUID, exitCode *int32, lastErr string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.called = true
|
|
f.exitID = id
|
|
f.exitCode = exitCode
|
|
f.lastErr = lastErr
|
|
return nil
|
|
}
|
|
|
|
func testCfg() SupervisorConfig {
|
|
return SupervisorConfig{
|
|
KillGrace: 2 * time.Second,
|
|
OutBufferLines: 100,
|
|
OutTailBytes: 2000,
|
|
WSSendBuffer: 16,
|
|
ShutdownGrace: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
func quickExit() SpawnParams {
|
|
p := SpawnParams{ProfileID: uuid.New()}
|
|
if runtime.GOOS == "windows" {
|
|
p.Command, p.Args = "cmd", []string{"/c", "exit", "0"}
|
|
} else {
|
|
p.Command, p.Args = "sh", []string{"-c", "exit 0"}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func sleeper() SpawnParams {
|
|
p := SpawnParams{ProfileID: uuid.New()}
|
|
if runtime.GOOS == "windows" {
|
|
p.Command, p.Args = "cmd", []string{"/c", "timeout", "/t", "30", "/nobreak"}
|
|
} else {
|
|
p.Command, p.Args = "sh", []string{"-c", "sleep 30"}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// TestSpawn_SelfExit_RecordsCrash 验证:未被我方终止而自行退出 → crashed,
|
|
// 且 onExit 把退出码落库(monitor 在 close(done) 前已完成 onExit)。
|
|
func TestSpawn_SelfExit_RecordsCrash(t *testing.T) {
|
|
repo := &fakeRepo{}
|
|
sup := NewSupervisor(repo, testCfg(), nil)
|
|
params := quickExit()
|
|
|
|
proc, err := sup.Spawn(params)
|
|
if err != nil {
|
|
t.Fatalf("spawn: %v", err)
|
|
}
|
|
select {
|
|
case <-proc.done:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("process did not exit")
|
|
}
|
|
|
|
if got := sup.StatusOf(params.ProfileID); got != StatusCrashed {
|
|
t.Fatalf("status = %q, want crashed (self-exit not initiated by us)", got)
|
|
}
|
|
repo.mu.Lock()
|
|
defer repo.mu.Unlock()
|
|
if !repo.called {
|
|
t.Fatal("UpdateRunProfileExit not called on exit")
|
|
}
|
|
if repo.exitID != params.ProfileID {
|
|
t.Fatalf("exit recorded for wrong profile: %v", repo.exitID)
|
|
}
|
|
if repo.exitCode == nil || *repo.exitCode != 0 {
|
|
t.Fatalf("exit code = %v, want 0", repo.exitCode)
|
|
}
|
|
}
|
|
|
|
// TestStop_TerminatesAndMarksStopped 验证 Stop 整树终止并判定为 stopped。
|
|
func TestStop_TerminatesAndMarksStopped(t *testing.T) {
|
|
repo := &fakeRepo{}
|
|
sup := NewSupervisor(repo, testCfg(), nil)
|
|
params := sleeper()
|
|
|
|
proc, err := sup.Spawn(params)
|
|
if err != nil {
|
|
t.Fatalf("spawn: %v", err)
|
|
}
|
|
if got := sup.StatusOf(params.ProfileID); got != StatusRunning {
|
|
t.Fatalf("status = %q, want running", got)
|
|
}
|
|
|
|
if err := sup.Stop(context.Background(), params.ProfileID, testCfg().KillGrace); err != nil {
|
|
t.Fatalf("stop: %v", err)
|
|
}
|
|
// Stop 已等 done;onExit 在 close(done) 前完成。
|
|
select {
|
|
case <-proc.done:
|
|
default:
|
|
t.Fatal("done not closed after Stop")
|
|
}
|
|
if got := sup.StatusOf(params.ProfileID); got != StatusStopped {
|
|
t.Fatalf("status = %q, want stopped", got)
|
|
}
|
|
if _, ok := sup.Get(params.ProfileID); ok {
|
|
t.Fatal("proc still registered after stop")
|
|
}
|
|
}
|
|
|
|
// TestSpawn_AlreadyRunning 验证同一 profile 不能并发拉起两个进程。
|
|
func TestSpawn_AlreadyRunning(t *testing.T) {
|
|
sup := NewSupervisor(&fakeRepo{}, testCfg(), nil)
|
|
params := sleeper()
|
|
|
|
if _, err := sup.Spawn(params); err != nil {
|
|
t.Fatalf("first spawn: %v", err)
|
|
}
|
|
defer func() { _ = sup.Stop(context.Background(), params.ProfileID, testCfg().KillGrace) }()
|
|
|
|
if _, err := sup.Spawn(params); err != ErrAlreadyRunning {
|
|
t.Fatalf("second spawn err = %v, want ErrAlreadyRunning", err)
|
|
}
|
|
}
|
|
|
|
// TestStop_NotRunning 验证停止未运行的 profile 返回 ErrNotRunning。
|
|
func TestStop_NotRunning(t *testing.T) {
|
|
sup := NewSupervisor(&fakeRepo{}, testCfg(), nil)
|
|
if err := sup.Stop(context.Background(), uuid.New(), testCfg().KillGrace); err != ErrNotRunning {
|
|
t.Fatalf("err = %v, want ErrNotRunning", err)
|
|
}
|
|
}
|