1078 lines
29 KiB
Go
1078 lines
29 KiB
Go
package process
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/types"
|
|
)
|
|
|
|
func TestInteractiveRunner_Start(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
req := InteractiveStartRequest{
|
|
SessionID: "test-session",
|
|
Command: []string{"echo", "hello"},
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
if info.ID == "" {
|
|
t.Error("Start() returned empty ID")
|
|
}
|
|
if info.SessionID != "test-session" {
|
|
t.Errorf("Start() SessionID = %q, want %q", info.SessionID, "test-session")
|
|
}
|
|
if info.Status != types.ProcessStatusRunning {
|
|
t.Errorf("Start() Status = %v, want %v", info.Status, types.ProcessStatusRunning)
|
|
}
|
|
|
|
// Wait for process to exit
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// Process should have completed
|
|
procInfo, ok := runner.Get(info.ID, false)
|
|
if !ok {
|
|
// Process may have been removed after exit, which is expected
|
|
return
|
|
}
|
|
if procInfo.Status == types.ProcessStatusRunning {
|
|
t.Error("Process should have exited")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_Start_ValidationErrors(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
tests := []struct {
|
|
name string
|
|
req InteractiveStartRequest
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "missing session_id",
|
|
req: InteractiveStartRequest{
|
|
Command: []string{"echo"},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "missing command",
|
|
req: InteractiveStartRequest{
|
|
SessionID: "test",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "valid request",
|
|
req: InteractiveStartRequest{
|
|
SessionID: "test",
|
|
Command: []string{"echo"},
|
|
ImmediateStart: true,
|
|
},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := runner.Start(context.Background(), tt.req)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("Start() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_DeferredStart(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Start without ImmediateStart - process should be deferred
|
|
// Use 'cat' which blocks waiting for input, giving us time to check status
|
|
req := InteractiveStartRequest{
|
|
SessionID: "deferred-session",
|
|
Command: []string{"cat"},
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Try to write - should fail because process not started
|
|
err = runner.WriteStdin(info.ID, "test")
|
|
if err == nil {
|
|
t.Error("WriteStdin() should fail for deferred process")
|
|
}
|
|
|
|
// Trigger start via resize
|
|
err = runner.ResizeBySession("deferred-session", 80, 24)
|
|
if err != nil {
|
|
t.Fatalf("ResizeBySession() error = %v", err)
|
|
}
|
|
|
|
// Give process time to start
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Now get process info - process should exist and be running
|
|
procInfo, ok := runner.GetBySession("deferred-session")
|
|
if !ok {
|
|
t.Fatal("GetBySession() should find process after resize")
|
|
}
|
|
if procInfo.Status != types.ProcessStatusRunning {
|
|
t.Errorf("Process status = %v, want running", procInfo.Status)
|
|
}
|
|
|
|
// Clean up - stop the process
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = runner.Stop(ctx, info.ID)
|
|
}
|
|
|
|
func TestInteractiveRunner_WriteStdin(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Start cat process that echoes input
|
|
req := InteractiveStartRequest{
|
|
SessionID: "stdin-test",
|
|
Command: []string{"cat"},
|
|
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)
|
|
|
|
// Write to stdin
|
|
err = runner.WriteStdin(info.ID, "hello\n")
|
|
if err != nil {
|
|
t.Errorf("WriteStdin() error = %v", err)
|
|
}
|
|
|
|
// Stop the process
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = runner.Stop(ctx, info.ID)
|
|
}
|
|
|
|
func TestInteractiveRunner_Stop(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Start a long-running process
|
|
req := InteractiveStartRequest{
|
|
SessionID: "stop-test",
|
|
Command: []string{"sleep", "60"},
|
|
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)
|
|
|
|
// Stop the process
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
err = runner.Stop(ctx, info.ID)
|
|
if err != nil {
|
|
t.Errorf("Stop() error = %v", err)
|
|
}
|
|
|
|
// Process should be removed after stop
|
|
time.Sleep(200 * time.Millisecond)
|
|
_, ok := runner.Get(info.ID, false)
|
|
if ok {
|
|
t.Error("Process should be removed after stop")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_GetBuffer(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
req := InteractiveStartRequest{
|
|
SessionID: "buffer-test",
|
|
Command: []string{"echo", "buffered output"},
|
|
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()
|
|
})
|
|
|
|
req := InteractiveStartRequest{
|
|
SessionID: "callback-test",
|
|
Command: []string{"echo", "callback test"},
|
|
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) {
|
|
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)
|
|
|
|
req := InteractiveStartRequest{
|
|
SessionID: "direct-output-test",
|
|
Command: []string{"echo", "direct"},
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Wait for output
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Clear direct output
|
|
err = runner.ClearDirectOutput(info.ID)
|
|
// May fail if process already exited, that's OK
|
|
_ = err
|
|
|
|
// Check if writer received data
|
|
writer.mu.Lock()
|
|
gotData := len(writer.data) > 0
|
|
writer.mu.Unlock()
|
|
|
|
if gotData {
|
|
t.Log("Direct writer received data")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
req := InteractiveStartRequest{
|
|
SessionID: "pty-writer-test",
|
|
Command: []string{"cat"},
|
|
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
|
|
req := InteractiveStartRequest{
|
|
SessionID: "running-test",
|
|
Command: []string{"sleep", "10"},
|
|
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)
|
|
req := InteractiveStartRequest{
|
|
SessionID: "pending-test",
|
|
Command: []string{"cat"},
|
|
// 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)
|
|
}
|
|
|
|
func TestContainsDSRQuery(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data []byte
|
|
want bool
|
|
}{
|
|
{"ESC[6n", []byte("\x1b[6n"), true},
|
|
{"ESC[?6n", []byte("\x1b[?6n"), true},
|
|
{"mixed content with DSR", []byte("text\x1b[6nmore"), true},
|
|
{"no escape", []byte("hello world"), false},
|
|
{"partial ESC[6", []byte("\x1b[6"), false},
|
|
{"ESC[c is not DSR", []byte("\x1b[c"), false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := containsDSRQuery(tt.data); got != tt.want {
|
|
t.Errorf("containsDSRQuery(%q) = %v, want %v", tt.data, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestContainsDA1Query(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
data []byte
|
|
want bool
|
|
}{
|
|
{"ESC[c (DA1 no param)", []byte("\x1b[c"), true},
|
|
{"ESC[0c (DA1 explicit 0)", []byte("\x1b[0c"), true},
|
|
{"mixed with DA1", []byte("text\x1b[cmore"), true},
|
|
{"both DSR and DA1", []byte("\x1b[6n\x1b[c"), true},
|
|
{"no escape", []byte("hello world"), false},
|
|
{"ESC[1c is cursor forward, not DA1", []byte("\x1b[1c"), false},
|
|
{"ESC[2c is cursor forward, not DA1", []byte("\x1b[2c"), false},
|
|
{"ESC[5c is cursor forward, not DA1", []byte("\x1b[5c"), false},
|
|
{"ESC[9c is cursor forward, not DA1", []byte("\x1b[9c"), false},
|
|
{"partial ESC[", []byte("\x1b["), false},
|
|
{"empty data", []byte{}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := containsDA1Query(tt.data); got != tt.want {
|
|
t.Errorf("containsDA1Query(%q) = %v, want %v", tt.data, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_ResizeByProcessID(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Start a deferred process
|
|
req := InteractiveStartRequest{
|
|
SessionID: "resize-pid-test",
|
|
Command: []string{"cat"},
|
|
}
|
|
info, err := runner.Start(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
// Process should not be running yet
|
|
if runner.IsProcessRunning(info.ID) {
|
|
t.Error("process should not be running before resize")
|
|
}
|
|
|
|
// Trigger start via ResizeByProcessID
|
|
if err := runner.ResizeByProcessID(info.ID, 80, 24); err != nil {
|
|
t.Fatalf("ResizeByProcessID() error = %v", err)
|
|
}
|
|
|
|
// Give process time to start
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
if !runner.IsProcessRunning(info.ID) {
|
|
t.Error("process should be running after ResizeByProcessID")
|
|
}
|
|
|
|
// Subsequent resize should succeed
|
|
if err := runner.ResizeByProcessID(info.ID, 120, 40); err != nil {
|
|
t.Errorf("second ResizeByProcessID() error = %v", err)
|
|
}
|
|
|
|
// Non-existent process should fail
|
|
if err := runner.ResizeByProcessID("nonexistent", 80, 24); err == nil {
|
|
t.Error("ResizeByProcessID() should fail for nonexistent process")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = runner.Stop(ctx, info.ID)
|
|
}
|
|
|
|
func TestInteractiveRunner_IsUserShellIsolation(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Start a regular (non-user-shell) process
|
|
agentReq := InteractiveStartRequest{
|
|
SessionID: "isolation-test",
|
|
Command: []string{"cat"},
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
agentInfo, err := runner.Start(context.Background(), agentReq)
|
|
if err != nil {
|
|
t.Fatalf("Start() agent error = %v", err)
|
|
}
|
|
|
|
// Start a user shell process for the same session
|
|
shellReq := InteractiveStartRequest{
|
|
SessionID: "isolation-test",
|
|
Command: []string{"cat"},
|
|
IsUserShell: true,
|
|
ImmediateStart: true,
|
|
DefaultCols: 80,
|
|
DefaultRows: 24,
|
|
}
|
|
shellInfo, err := runner.Start(context.Background(), shellReq)
|
|
if err != nil {
|
|
t.Fatalf("Start() shell error = %v", err)
|
|
}
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// ResizeBySession should target the agent process, not the user shell
|
|
if err := runner.ResizeBySession("isolation-test", 120, 40); err != nil {
|
|
t.Errorf("ResizeBySession() error = %v", err)
|
|
}
|
|
|
|
// GetPtyWriterBySession should return the agent process, not the user shell
|
|
_, procID, err := runner.GetPtyWriterBySession("isolation-test")
|
|
if err != nil {
|
|
t.Fatalf("GetPtyWriterBySession() error = %v", err)
|
|
}
|
|
if procID != agentInfo.ID {
|
|
t.Errorf("GetPtyWriterBySession() returned process %q, want agent process %q", procID, agentInfo.ID)
|
|
}
|
|
|
|
// SetDirectOutput on user shell should NOT track at session level
|
|
writer := &mockDirectWriter{}
|
|
if err := runner.SetDirectOutput(shellInfo.ID, writer); err != nil {
|
|
t.Fatalf("SetDirectOutput() shell error = %v", err)
|
|
}
|
|
// Session-level WebSocket should NOT be set for user shells
|
|
if runner.HasActiveWebSocketBySession("isolation-test") {
|
|
t.Error("user shell should not set session-level WebSocket tracking")
|
|
}
|
|
|
|
// SetDirectOutput on agent process SHOULD track at session level
|
|
agentWriter := &mockDirectWriter{}
|
|
if err := runner.SetDirectOutput(agentInfo.ID, agentWriter); err != nil {
|
|
t.Fatalf("SetDirectOutput() agent error = %v", err)
|
|
}
|
|
if !runner.HasActiveWebSocketBySession("isolation-test") {
|
|
t.Error("agent process should set session-level WebSocket tracking")
|
|
}
|
|
|
|
// ClearDirectOutputBySession should clear agent but not user shell
|
|
runner.ClearDirectOutputBySession("isolation-test")
|
|
|
|
// Agent should have its direct output cleared
|
|
if runner.HasActiveWebSocket(agentInfo.ID) {
|
|
t.Error("agent WebSocket should be cleared after ClearDirectOutputBySession")
|
|
}
|
|
// User shell should still have its direct output
|
|
if !runner.HasActiveWebSocket(shellInfo.ID) {
|
|
t.Error("user shell WebSocket should NOT be cleared by ClearDirectOutputBySession")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = runner.Stop(ctx, agentInfo.ID)
|
|
_ = runner.Stop(ctx, shellInfo.ID)
|
|
}
|
|
|
|
// --- User Shell Tests ---
|
|
|
|
func TestInteractiveRunner_CreateUserShell_First(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
result := runner.CreateUserShell("session-1")
|
|
|
|
if result.TerminalID == "" {
|
|
t.Error("CreateUserShell() returned empty TerminalID")
|
|
}
|
|
if !strings.HasPrefix(result.TerminalID, "shell-") {
|
|
t.Errorf("CreateUserShell() TerminalID = %q, want prefix 'shell-'", result.TerminalID)
|
|
}
|
|
if result.Label != "Terminal" {
|
|
t.Errorf("CreateUserShell() Label = %q, want 'Terminal'", result.Label)
|
|
}
|
|
if result.Closable {
|
|
t.Error("CreateUserShell() first terminal should not be closable")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_CreateUserShell_Subsequent(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
first := runner.CreateUserShell("session-1")
|
|
second := runner.CreateUserShell("session-1")
|
|
|
|
if first.TerminalID == second.TerminalID {
|
|
t.Error("CreateUserShell() should return different terminal IDs")
|
|
}
|
|
if second.Label != "Terminal 2" {
|
|
t.Errorf("CreateUserShell() second Label = %q, want 'Terminal 2'", second.Label)
|
|
}
|
|
if !second.Closable {
|
|
t.Error("CreateUserShell() second terminal should be closable")
|
|
}
|
|
|
|
third := runner.CreateUserShell("session-1")
|
|
if third.Label != "Terminal 3" {
|
|
t.Errorf("CreateUserShell() third Label = %q, want 'Terminal 3'", third.Label)
|
|
}
|
|
if !third.Closable {
|
|
t.Error("CreateUserShell() third terminal should be closable")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_CreateUserShell_DifferentSessions(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
r1 := runner.CreateUserShell("session-1")
|
|
r2 := runner.CreateUserShell("session-2")
|
|
|
|
// Both should be "Terminal" (first in each session)
|
|
if r1.Label != "Terminal" {
|
|
t.Errorf("session-1 Label = %q, want 'Terminal'", r1.Label)
|
|
}
|
|
if r2.Label != "Terminal" {
|
|
t.Errorf("session-2 Label = %q, want 'Terminal'", r2.Label)
|
|
}
|
|
if r1.Closable || r2.Closable {
|
|
t.Error("first terminal in each session should not be closable")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_RegisterScriptShell(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
runner.RegisterScriptShell("session-1", "script-abc", "npm start", "npm run start")
|
|
|
|
// Should appear in list
|
|
shells := runner.ListUserShells("session-1")
|
|
|
|
// Should have 2: auto-created "Terminal" + registered script
|
|
if len(shells) != 2 {
|
|
t.Fatalf("ListUserShells() returned %d shells, want 2", len(shells))
|
|
}
|
|
|
|
// Find the script shell
|
|
var scriptShell *UserShellInfo
|
|
for i := range shells {
|
|
if shells[i].TerminalID == "script-abc" {
|
|
scriptShell = &shells[i]
|
|
break
|
|
}
|
|
}
|
|
if scriptShell == nil {
|
|
t.Fatal("script shell not found in list")
|
|
} else {
|
|
if scriptShell.Label != "npm start" {
|
|
t.Errorf("script shell Label = %q, want 'npm start'", scriptShell.Label)
|
|
}
|
|
if scriptShell.InitialCommand != "npm run start" {
|
|
t.Errorf("script shell InitialCommand = %q, want 'npm run start'", scriptShell.InitialCommand)
|
|
}
|
|
if !scriptShell.Closable {
|
|
t.Error("script shell should be closable")
|
|
}
|
|
}
|
|
if scriptShell.ProcessID != "" {
|
|
t.Errorf("script shell should have empty ProcessID before WebSocket connect, got %q", scriptShell.ProcessID)
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_RegisterScriptShell_DoesNotAffectShellCount(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Register a script terminal
|
|
runner.RegisterScriptShell("session-1", "script-abc", "Build", "npm run build")
|
|
|
|
// Create a plain shell - should be "Terminal" (first plain shell), not "Terminal 2"
|
|
result := runner.CreateUserShell("session-1")
|
|
if result.Label != "Terminal" {
|
|
t.Errorf("CreateUserShell() Label = %q, want 'Terminal' (scripts should not count)", result.Label)
|
|
}
|
|
if result.Closable {
|
|
t.Error("first plain shell should not be closable regardless of script terminals")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_ListUserShells_AutoCreatesFirst(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// First call should auto-create "Terminal"
|
|
shells := runner.ListUserShells("session-1")
|
|
|
|
if len(shells) != 1 {
|
|
t.Fatalf("ListUserShells() returned %d shells, want 1", len(shells))
|
|
}
|
|
if shells[0].Label != "Terminal" {
|
|
t.Errorf("auto-created shell Label = %q, want 'Terminal'", shells[0].Label)
|
|
}
|
|
if shells[0].Closable {
|
|
t.Error("auto-created first shell should not be closable")
|
|
}
|
|
if shells[0].Running {
|
|
t.Error("auto-created shell should not be running (no process)")
|
|
}
|
|
if !strings.HasPrefix(shells[0].TerminalID, "shell-") {
|
|
t.Errorf("auto-created shell TerminalID = %q, want prefix 'shell-'", shells[0].TerminalID)
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_ListUserShells_StableAfterAutoCreate(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// First call auto-creates
|
|
shells1 := runner.ListUserShells("session-1")
|
|
// Second call should return the same list (not create another)
|
|
shells2 := runner.ListUserShells("session-1")
|
|
|
|
if len(shells1) != 1 || len(shells2) != 1 {
|
|
t.Fatalf("ListUserShells() should return 1 shell each time, got %d and %d", len(shells1), len(shells2))
|
|
}
|
|
if shells1[0].TerminalID != shells2[0].TerminalID {
|
|
t.Error("ListUserShells() should return the same terminal ID across calls")
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_ListUserShells_Sorted(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Create shells with a small time gap
|
|
runner.CreateUserShell("session-1")
|
|
time.Sleep(10 * time.Millisecond)
|
|
runner.CreateUserShell("session-1")
|
|
time.Sleep(10 * time.Millisecond)
|
|
runner.RegisterScriptShell("session-1", "script-1", "Build", "make build")
|
|
|
|
shells := runner.ListUserShells("session-1")
|
|
if len(shells) != 3 {
|
|
t.Fatalf("ListUserShells() returned %d shells, want 3", len(shells))
|
|
}
|
|
|
|
// Should be sorted by creation time
|
|
for i := 1; i < len(shells); i++ {
|
|
if shells[i].CreatedAt.Before(shells[i-1].CreatedAt) {
|
|
t.Errorf("shells not sorted by creation time: shell[%d] (%v) before shell[%d] (%v)",
|
|
i, shells[i].CreatedAt, i-1, shells[i-1].CreatedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_ListUserShells_IsolatedBySessions(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
runner.CreateUserShell("session-1")
|
|
runner.CreateUserShell("session-1")
|
|
runner.CreateUserShell("session-2")
|
|
|
|
shells1 := runner.ListUserShells("session-1")
|
|
shells2 := runner.ListUserShells("session-2")
|
|
|
|
if len(shells1) != 2 {
|
|
t.Errorf("session-1 should have 2 shells, got %d", len(shells1))
|
|
}
|
|
if len(shells2) != 1 {
|
|
t.Errorf("session-2 should have 1 shell, got %d", len(shells2))
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_StopUserShell(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Create a shell
|
|
result := runner.CreateUserShell("session-1")
|
|
|
|
// Verify it's in the list
|
|
shells := runner.ListUserShells("session-1")
|
|
found := false
|
|
for _, s := range shells {
|
|
if s.TerminalID == result.TerminalID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("created shell not found in list")
|
|
}
|
|
|
|
// Stop the shell (no process running, so this should remove the entry)
|
|
ctx := context.Background()
|
|
err := runner.StopUserShell(ctx, "session-1", result.TerminalID)
|
|
// Error is expected since there's no process to stop
|
|
_ = err
|
|
|
|
// Shell should be removed from tracking
|
|
shells = runner.ListUserShells("session-1")
|
|
for _, s := range shells {
|
|
if s.TerminalID == result.TerminalID {
|
|
t.Error("stopped shell should be removed from list")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_StopUserShell_NonExistent(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Stopping a non-existent shell should not error
|
|
ctx := context.Background()
|
|
err := runner.StopUserShell(ctx, "session-1", "nonexistent")
|
|
if err != nil {
|
|
t.Errorf("StopUserShell() for non-existent shell should return nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_StopUserShell_ScriptTerminal(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Register and then stop a script terminal
|
|
runner.RegisterScriptShell("session-1", "script-abc", "Build", "npm run build")
|
|
|
|
ctx := context.Background()
|
|
err := runner.StopUserShell(ctx, "session-1", "script-abc")
|
|
_ = err // Error expected since no process
|
|
|
|
// Script should be removed from list (auto-created "Terminal" may still appear)
|
|
shells := runner.ListUserShells("session-1")
|
|
for _, s := range shells {
|
|
if s.TerminalID == "script-abc" {
|
|
t.Error("stopped script terminal should be removed from list")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInteractiveRunner_CreateUserShell_AtomicRegistration(t *testing.T) {
|
|
log := newTestLogger(t)
|
|
runner := NewInteractiveRunner(nil, log, 2*1024*1024)
|
|
|
|
// Create a shell - it should be immediately visible in ListUserShells
|
|
result := runner.CreateUserShell("session-1")
|
|
|
|
shells := runner.ListUserShells("session-1")
|
|
found := false
|
|
for _, s := range shells {
|
|
if s.TerminalID == result.TerminalID {
|
|
found = true
|
|
if s.Label != result.Label {
|
|
t.Errorf("shell Label mismatch: list=%q, create=%q", s.Label, result.Label)
|
|
}
|
|
if s.Closable != result.Closable {
|
|
t.Errorf("shell Closable mismatch: list=%v, create=%v", s.Closable, result.Closable)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("CreateUserShell() result should be immediately visible in ListUserShells()")
|
|
}
|
|
}
|