420 lines
10 KiB
Go
420 lines
10 KiB
Go
package process
|
|
|
|
import (
|
|
"context"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/types"
|
|
)
|
|
|
|
func TestInteractiveRunner_GetBuffer(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
cmd, env := fixtureExec("echo buffered")
|
|
req := InteractiveStartRequest{
|
|
SessionID: "buffer-test",
|
|
Command: cmd,
|
|
Env: env,
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Wait for output
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
buffer, ok := runner.GetBuffer(info.ID)
|
|
if !ok {
|
|
// Process may have exited and been removed
|
|
return
|
|
}
|
|
|
|
// Check if output was captured
|
|
combined := ""
|
|
for _, chunk := range buffer {
|
|
combined += chunk.Data
|
|
}
|
|
|
|
if !strings.Contains(combined, "buffered") {
|
|
t.Logf("Buffer contents: %q", combined)
|
|
// Note: Output might be empty if process exited too quickly
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_Callbacks(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
var statusReceived bool
|
|
var mu sync.Mutex
|
|
|
|
runner.SetOutputCallback(func(output *types.ProcessOutput) {
|
|
// Output callback received
|
|
})
|
|
|
|
runner.SetStatusCallback(func(status *types.ProcessStatusUpdate) {
|
|
mu.Lock()
|
|
statusReceived = true
|
|
mu.Unlock()
|
|
})
|
|
|
|
cmd, env := fixtureExec("echo callback")
|
|
req := InteractiveStartRequest{
|
|
SessionID: "callback-test",
|
|
Command: cmd,
|
|
Env: env,
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
|
|
_, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Wait for callbacks
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
if !statusReceived {
|
|
t.Error("Status callback should have been called")
|
|
}
|
|
// Output callback may or may not be called depending on timing
|
|
mu.Unlock()
|
|
}
|
|
|
|
func TestInteractiveRunner_TurnCompleteCallback(t *testing.T) {
|
|
// This test hardcodes `bash -c "echo '$ '"` to produce a literal "$ "
|
|
// prompt and exercise the PromptPattern turn-complete detector. Migrating
|
|
// it to fixtureExec would lose the bash-only quoting/echo semantics the
|
|
// detector depends on. Windows CI passes only because windows-latest ships
|
|
// Git Bash in PATH; a bare Windows host without Git Bash would fail at
|
|
// runner.Start. Skip rather than pretend it's portable.
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires bash on PATH (Git Bash on Windows); turn-complete pattern is bash-specific")
|
|
}
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
var turnCompleteCalled bool
|
|
var turnSessionID string
|
|
var mu sync.Mutex
|
|
|
|
runner.SetTurnCompleteCallback(func(sessionID string) {
|
|
mu.Lock()
|
|
turnCompleteCalled = true
|
|
turnSessionID = sessionID
|
|
mu.Unlock()
|
|
})
|
|
|
|
// Start with a prompt pattern that matches "$ "
|
|
req := InteractiveStartRequest{
|
|
SessionID: "turn-test",
|
|
Command: []string{"bash", "-c", "echo '$ '"},
|
|
PromptPattern: `\$ $`,
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
|
|
_, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Wait for turn detection
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
if turnCompleteCalled && turnSessionID != "turn-test" {
|
|
t.Errorf("Turn complete callback received wrong session ID: %q", turnSessionID)
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
|
|
func TestInteractiveRunner_DirectOutput(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
cmd, env := fixtureExec("cat")
|
|
req := InteractiveStartRequest{
|
|
SessionID: "direct-output-test",
|
|
Command: cmd,
|
|
Env: env,
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Wait for process to be running
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for !runner.IsProcessRunning(info.ID) {
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for process to start")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
// Create a mock direct writer
|
|
writer := &mockDirectWriter{}
|
|
|
|
// Set direct output
|
|
err = runner.SetDirectOutput(info.ID, writer)
|
|
if err != nil {
|
|
t.Errorf("SetDirectOutput() error = %v", err)
|
|
}
|
|
|
|
// Clear direct output
|
|
err = runner.ClearDirectOutput(info.ID)
|
|
if err != nil {
|
|
t.Errorf("ClearDirectOutput() error = %v", err)
|
|
}
|
|
|
|
// Stop the process
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = runner.Stop(ctx, info.ID)
|
|
}
|
|
|
|
// mockDirectWriter implements DirectOutputWriter for testing
|
|
type mockDirectWriter struct {
|
|
mu sync.Mutex
|
|
data []byte
|
|
closed bool
|
|
}
|
|
|
|
func (w *mockDirectWriter) Write(p []byte) (n int, err error) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.data = append(w.data, p...)
|
|
return len(p), nil
|
|
}
|
|
|
|
func (w *mockDirectWriter) Close() error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.closed = true
|
|
return nil
|
|
}
|
|
|
|
func TestInteractiveRunner_GetPtyWriter(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
cmd, env := fixtureExec("cat")
|
|
req := InteractiveStartRequest{
|
|
SessionID: "pty-writer-test",
|
|
Command: cmd,
|
|
Env: env,
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Give process time to start
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Get PTY writer
|
|
writer, err := runner.GetPtyWriter(info.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetPtyWriter() error = %v", err)
|
|
}
|
|
|
|
if writer == nil {
|
|
t.Error("GetPtyWriter() returned nil writer")
|
|
}
|
|
|
|
// Stop the process
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = runner.Stop(ctx, info.ID)
|
|
}
|
|
|
|
func TestInteractiveRunner_GetPtyWriter_NotStarted(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Start without ImmediateStart
|
|
req := InteractiveStartRequest{
|
|
SessionID: "not-started",
|
|
Command: []string{"cat"},
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Try to get PTY writer before process starts
|
|
_, err = runner.GetPtyWriter(info.ID)
|
|
if err == nil {
|
|
t.Error("GetPtyWriter() should fail for deferred process")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_NotFound(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Test various methods with non-existent process
|
|
_, ok := runner.Get("nonexistent", false)
|
|
if ok {
|
|
t.Error("Get() should return false for nonexistent process")
|
|
}
|
|
|
|
_, ok = runner.GetBySession("nonexistent")
|
|
if ok {
|
|
t.Error("GetBySession() should return false for nonexistent session")
|
|
}
|
|
|
|
_, ok = runner.GetBuffer("nonexistent")
|
|
if ok {
|
|
t.Error("GetBuffer() should return false for nonexistent process")
|
|
}
|
|
|
|
err := runner.WriteStdin("nonexistent", "data")
|
|
if err == nil {
|
|
t.Error("WriteStdin() should fail for nonexistent process")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
err = runner.Stop(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Error("Stop() should fail for nonexistent process")
|
|
}
|
|
|
|
err = runner.SetDirectOutput("nonexistent", nil)
|
|
if err == nil {
|
|
t.Error("SetDirectOutput() should fail for nonexistent process")
|
|
}
|
|
|
|
_, err = runner.GetPtyWriter("nonexistent")
|
|
if err == nil {
|
|
t.Error("GetPtyWriter() should fail for nonexistent process")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_IsProcessRunning(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Non-existent process
|
|
if runner.IsProcessRunning("nonexistent") {
|
|
t.Error("IsProcessRunning() should return false for nonexistent process")
|
|
}
|
|
|
|
// Start a process
|
|
cmd, env := fixtureExec("sleep 10")
|
|
req := InteractiveStartRequest{
|
|
SessionID: "running-test",
|
|
Command: cmd,
|
|
Env: env,
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Give process time to start
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Process should be running
|
|
if !runner.IsProcessRunning(info.ID) {
|
|
t.Error("IsProcessRunning() should return true for running process")
|
|
}
|
|
|
|
// Stop the process
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = runner.Stop(ctx, info.ID)
|
|
|
|
// Give it time to clean up
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Process should no longer be running
|
|
if runner.IsProcessRunning(info.ID) {
|
|
t.Error("IsProcessRunning() should return false after stop")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_IsProcessReadyOrPending(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Non-existent process
|
|
if runner.IsProcessReadyOrPending("nonexistent") {
|
|
t.Error("IsProcessReadyOrPending() should return false for nonexistent process")
|
|
}
|
|
|
|
// Start a deferred process (not started yet)
|
|
cmd, env := fixtureExec("cat")
|
|
req := InteractiveStartRequest{
|
|
SessionID: "pending-test",
|
|
Command: cmd,
|
|
Env: env,
|
|
// ImmediateStart: false (deferred)
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Deferred process should be "ready or pending"
|
|
if !runner.IsProcessReadyOrPending(info.ID) {
|
|
t.Error("IsProcessReadyOrPending() should return true for pending process")
|
|
}
|
|
|
|
// But not "running" yet
|
|
if runner.IsProcessRunning(info.ID) {
|
|
t.Error("IsProcessRunning() should return false for pending process")
|
|
}
|
|
|
|
// Trigger start via resize
|
|
err = runner.ResizeBySession("pending-test", 80, 24)
|
|
if err != nil {
|
|
t.Fatalf("ResizeBySession() error = %v", err)
|
|
}
|
|
|
|
// Give process time to start
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Now it should be both "running" and "ready or pending"
|
|
if !runner.IsProcessRunning(info.ID) {
|
|
t.Error("IsProcessRunning() should return true after start")
|
|
}
|
|
if !runner.IsProcessReadyOrPending(info.ID) {
|
|
t.Error("IsProcessReadyOrPending() should return true for running process")
|
|
}
|
|
|
|
// Stop the process
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = runner.Stop(ctx, info.ID)
|
|
}
|