feat: update readme & agent TUI reliability (#239)
This commit is contained in:
@@ -595,97 +595,183 @@ func TestInteractiveRunner_IsProcessReadyOrPending(t *testing.T) {
|
||||
_ = runner.Stop(ctx, info.ID)
|
||||
}
|
||||
|
||||
func TestCursorPositionQueryDetection(t *testing.T) {
|
||||
func TestContainsDSRQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantDSR bool // Device Status Report (cursor position query)
|
||||
wantDA bool // Device Attributes query
|
||||
name string
|
||||
data []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "cursor position query ESC[6n",
|
||||
data: []byte("\x1b[6n"),
|
||||
wantDSR: true,
|
||||
wantDA: false,
|
||||
},
|
||||
{
|
||||
name: "cursor position query ESC[?6n",
|
||||
data: []byte("\x1b[?6n"),
|
||||
wantDSR: true,
|
||||
wantDA: false,
|
||||
},
|
||||
{
|
||||
name: "device attributes query ESC[c",
|
||||
data: []byte("\x1b[c"),
|
||||
wantDSR: false,
|
||||
wantDA: true,
|
||||
},
|
||||
{
|
||||
name: "device attributes query ESC[0c",
|
||||
data: []byte("\x1b[0c"),
|
||||
wantDSR: false,
|
||||
wantDA: true,
|
||||
},
|
||||
{
|
||||
name: "no escape sequence",
|
||||
data: []byte("hello world"),
|
||||
wantDSR: false,
|
||||
wantDA: false,
|
||||
},
|
||||
{
|
||||
name: "mixed content with DSR",
|
||||
data: []byte("some text\x1b[6nmore text"),
|
||||
wantDSR: true,
|
||||
wantDA: false,
|
||||
},
|
||||
{
|
||||
name: "both DSR and DA",
|
||||
data: []byte("\x1b[6n\x1b[c"),
|
||||
wantDSR: true,
|
||||
wantDA: true,
|
||||
},
|
||||
{"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) {
|
||||
gotDSR := containsCursorPositionQuery(tt.data)
|
||||
gotDA := containsDeviceAttributesQuery(tt.data)
|
||||
|
||||
if gotDSR != tt.wantDSR {
|
||||
t.Errorf("containsCursorPositionQuery() = %v, want %v", gotDSR, tt.wantDSR)
|
||||
}
|
||||
if gotDA != tt.wantDA {
|
||||
t.Errorf("containsDeviceAttributesQuery() = %v, want %v", gotDA, tt.wantDA)
|
||||
if got := containsDSRQuery(tt.data); got != tt.want {
|
||||
t.Errorf("containsDSRQuery(%q) = %v, want %v", tt.data, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// containsCursorPositionQuery checks for DSR cursor position query (CSI 6 n)
|
||||
func containsCursorPositionQuery(data []byte) bool {
|
||||
return bytesContains(data, []byte("\x1b[6n")) || bytesContains(data, []byte("\x1b[?6n"))
|
||||
}
|
||||
|
||||
// containsDeviceAttributesQuery checks for DA query (CSI c or CSI 0 c)
|
||||
func containsDeviceAttributesQuery(data []byte) bool {
|
||||
return bytesContains(data, []byte("\x1b[c")) || bytesContains(data, []byte("\x1b[0c"))
|
||||
}
|
||||
|
||||
// bytesContains is a simple contains check (bytes.Contains equivalent for tests)
|
||||
func bytesContains(data, substr []byte) bool {
|
||||
for i := 0; i <= len(data)-len(substr); i++ {
|
||||
match := true
|
||||
for j := 0; j < len(substr); j++ {
|
||||
if data[i+j] != substr[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
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},
|
||||
}
|
||||
return 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 ---
|
||||
|
||||
Reference in New Issue
Block a user