feat: improve session terminals (#185)

This commit is contained in:
Carlos Florêncio
2026-02-05 04:09:04 +00:00
committed by GitHub
parent 1a69152f09
commit 06f7fe9795
31 changed files with 2327 additions and 504 deletions
@@ -687,3 +687,304 @@ func bytesContains(data, substr []byte) bool {
}
return false
}
// --- 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")
}
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()")
}
}