608 lines
14 KiB
Go
608 lines
14 KiB
Go
package shell
|
|
|
|
import (
|
|
"os"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
func newTestLogger() *logger.Logger {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{
|
|
Level: "error", // Suppress logs during tests
|
|
Format: "console",
|
|
})
|
|
return log
|
|
}
|
|
|
|
// TestDefaultConfig tests that DefaultConfig returns expected values
|
|
func TestDefaultConfig(t *testing.T) {
|
|
workDir := "/test/dir"
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
if cfg.WorkDir != workDir {
|
|
t.Errorf("expected WorkDir %q, got %q", workDir, cfg.WorkDir)
|
|
}
|
|
if cfg.Cols != 80 {
|
|
t.Errorf("expected Cols 80, got %d", cfg.Cols)
|
|
}
|
|
if cfg.Rows != 24 {
|
|
t.Errorf("expected Rows 24, got %d", cfg.Rows)
|
|
}
|
|
}
|
|
|
|
// TestDetectShell tests shell detection for the current OS
|
|
func TestDetectShell(t *testing.T) {
|
|
shell, args := detectShell()
|
|
|
|
if shell == "" {
|
|
t.Error("detectShell returned empty shell")
|
|
}
|
|
|
|
if runtime.GOOS == "windows" {
|
|
assertWindowsShell(t, shell)
|
|
return
|
|
}
|
|
assertUnixShell(t, shell, args)
|
|
}
|
|
|
|
// assertUnixShell verifies Unix-specific shell detection results.
|
|
func assertUnixShell(t *testing.T, shell string, args []string) {
|
|
t.Helper()
|
|
if _, err := os.Stat(shell); err != nil && shell != os.Getenv("SHELL") {
|
|
t.Logf("Warning: detected shell %q may not exist: %v", shell, err)
|
|
}
|
|
// No -l flag: login mode lets /etc/profile reset PATH and lose the
|
|
// container-set entries needed to find agent CLIs.
|
|
for _, a := range args {
|
|
if a == "-l" {
|
|
t.Errorf("expected no -l flag in Unix shell args, got %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assertWindowsShell verifies Windows-specific shell detection results.
|
|
func assertWindowsShell(t *testing.T, shell string) {
|
|
t.Helper()
|
|
validShells := map[string]bool{
|
|
"cmd.exe": true,
|
|
"powershell.exe": true,
|
|
"pwsh.exe": true,
|
|
}
|
|
if !validShells[shell] {
|
|
t.Errorf("unexpected Windows shell: %q", shell)
|
|
}
|
|
}
|
|
|
|
// TestDetectShellWithSHELLEnv tests shell detection respects SHELL env var on Unix
|
|
// when the shell actually exists.
|
|
func TestDetectShellWithSHELLEnv(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("SHELL env var is not used on Windows")
|
|
}
|
|
|
|
originalShell := os.Getenv("SHELL")
|
|
defer func() { _ = os.Setenv("SHELL", originalShell) }()
|
|
|
|
// Test with a shell that actually exists (/bin/sh is always present)
|
|
_ = os.Setenv("SHELL", "/bin/sh")
|
|
shell, args := detectShell()
|
|
|
|
if shell != "/bin/sh" {
|
|
t.Errorf("expected shell from SHELL env (/bin/sh), got %q", shell)
|
|
}
|
|
for _, a := range args {
|
|
if a == "-l" {
|
|
t.Errorf("expected no -l flag, got %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetectShellWithNonExistentSHELL tests shell detection falls back when SHELL doesn't exist
|
|
func TestDetectShellWithNonExistentSHELL(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("SHELL env var is not used on Windows")
|
|
}
|
|
|
|
originalShell := os.Getenv("SHELL")
|
|
defer func() { _ = os.Setenv("SHELL", originalShell) }()
|
|
|
|
// Set SHELL to a non-existent path (like host's zsh in a minimal container)
|
|
_ = os.Setenv("SHELL", "/usr/bin/this-shell-does-not-exist")
|
|
shell, _ := detectShell()
|
|
|
|
// Should fall back to a shell that exists, NOT the non-existent one
|
|
if shell == "/usr/bin/this-shell-does-not-exist" {
|
|
t.Errorf("expected fallback to an existing shell, but got non-existent %q", shell)
|
|
}
|
|
|
|
// Should be one of the common fallback shells
|
|
validShells := map[string]bool{
|
|
"/bin/bash": true,
|
|
"/bin/zsh": true,
|
|
"/bin/sh": true,
|
|
}
|
|
if !validShells[shell] {
|
|
t.Logf("Detected fallback shell: %q", shell)
|
|
}
|
|
}
|
|
|
|
// TestNewSession tests creating a new session
|
|
func TestNewSession(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
// PTY may not work in all CI environments
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
defer func() { _ = session.Stop() }()
|
|
|
|
// Verify session is running
|
|
status := session.Status()
|
|
if !status.Running {
|
|
t.Error("expected session to be running")
|
|
}
|
|
if status.Pid == 0 {
|
|
t.Error("expected non-zero PID")
|
|
}
|
|
if status.Cwd != workDir {
|
|
t.Errorf("expected Cwd %q, got %q", workDir, status.Cwd)
|
|
}
|
|
if status.Shell == "" {
|
|
t.Error("expected non-empty Shell")
|
|
}
|
|
if status.StartedAt.IsZero() {
|
|
t.Error("expected non-zero StartedAt")
|
|
}
|
|
}
|
|
|
|
// TestSessionStop tests stopping a session
|
|
func TestSessionStop(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
|
|
// Stop the session
|
|
if err := session.Stop(); err != nil {
|
|
t.Errorf("Stop failed: %v", err)
|
|
}
|
|
|
|
// Verify session is stopped
|
|
status := session.Status()
|
|
if status.Running {
|
|
t.Error("expected session to not be running after Stop")
|
|
}
|
|
|
|
// Calling Stop again should be idempotent
|
|
if err := session.Stop(); err != nil {
|
|
t.Errorf("second Stop failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSessionStopTimeoutIsShortForShutdown(t *testing.T) {
|
|
log := newTestLogger()
|
|
session := &Session{
|
|
logger: log,
|
|
running: true,
|
|
stopCh: make(chan struct{}),
|
|
doneCh: make(chan struct{}),
|
|
}
|
|
|
|
start := time.Now()
|
|
if err := session.Stop(); err != nil {
|
|
t.Errorf("Stop failed: %v", err)
|
|
}
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed > 700*time.Millisecond {
|
|
t.Fatalf("Stop took %s, want less than 700ms for shutdown fallback", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestSessionWrite tests writing to the shell
|
|
func TestSessionWrite(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
defer func() { _ = session.Stop() }()
|
|
|
|
// Write a simple command
|
|
data := []byte("echo hello\n")
|
|
n, err := session.Write(data)
|
|
if err != nil {
|
|
t.Errorf("Write failed: %v", err)
|
|
}
|
|
if n != len(data) {
|
|
t.Errorf("expected to write %d bytes, wrote %d", len(data), n)
|
|
}
|
|
}
|
|
|
|
// TestSessionWriteNotRunning tests writing to a stopped session
|
|
func TestSessionWriteNotRunning(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
|
|
// Stop the session first
|
|
_ = session.Stop()
|
|
|
|
// Write should fail
|
|
_, err = session.Write([]byte("echo hello\n"))
|
|
if err == nil {
|
|
t.Error("expected Write to fail on stopped session")
|
|
}
|
|
}
|
|
|
|
// TestSessionSubscribeUnsubscribe tests the subscriber pattern
|
|
func TestSessionSubscribeUnsubscribe(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
defer func() { _ = session.Stop() }()
|
|
|
|
// Create subscriber channel
|
|
ch := make(chan []byte, 100)
|
|
|
|
// Subscribe
|
|
session.Subscribe(ch)
|
|
|
|
// Write something to trigger output
|
|
_, _ = session.Write([]byte("echo test\n"))
|
|
|
|
// Wait a bit for output
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Unsubscribe
|
|
session.Unsubscribe(ch)
|
|
|
|
// The channel should have received some data (shell prompt + echo output)
|
|
select {
|
|
case data := <-ch:
|
|
if len(data) == 0 {
|
|
t.Error("expected non-empty output")
|
|
}
|
|
default:
|
|
// May not receive data immediately, that's okay
|
|
t.Log("no output received (may be timing-dependent)")
|
|
}
|
|
}
|
|
|
|
// TestSessionMultipleSubscribers tests multiple subscribers receive output
|
|
func TestSessionMultipleSubscribers(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
defer func() { _ = session.Stop() }()
|
|
|
|
// Create multiple subscriber channels
|
|
ch1 := make(chan []byte, 100)
|
|
ch2 := make(chan []byte, 100)
|
|
ch3 := make(chan []byte, 100)
|
|
|
|
session.Subscribe(ch1)
|
|
session.Subscribe(ch2)
|
|
session.Subscribe(ch3)
|
|
|
|
// Write something
|
|
_, _ = session.Write([]byte("echo multi\n"))
|
|
|
|
// Wait for output
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// All channels should potentially receive data
|
|
// Just verify no panics or errors occurred
|
|
|
|
session.Unsubscribe(ch1)
|
|
session.Unsubscribe(ch2)
|
|
session.Unsubscribe(ch3)
|
|
}
|
|
|
|
// TestSessionStatus tests Status returns correct information
|
|
func TestSessionStatus(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
defer func() { _ = session.Stop() }()
|
|
|
|
status := session.Status()
|
|
|
|
// Verify all fields
|
|
if !status.Running {
|
|
t.Error("expected Running to be true")
|
|
}
|
|
if status.Pid <= 0 {
|
|
t.Errorf("expected positive PID, got %d", status.Pid)
|
|
}
|
|
if status.Shell == "" {
|
|
t.Error("expected non-empty Shell")
|
|
}
|
|
if status.Cwd != workDir {
|
|
t.Errorf("expected Cwd %q, got %q", workDir, status.Cwd)
|
|
}
|
|
if status.StartedAt.IsZero() {
|
|
t.Error("expected non-zero StartedAt")
|
|
}
|
|
if time.Since(status.StartedAt) > 5*time.Second {
|
|
t.Error("StartedAt seems too old")
|
|
}
|
|
}
|
|
|
|
// TestSessionStatusAfterStop tests Status after stopping
|
|
func TestSessionStatusAfterStop(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
|
|
_ = session.Stop()
|
|
|
|
status := session.Status()
|
|
if status.Running {
|
|
t.Error("expected Running to be false after Stop")
|
|
}
|
|
}
|
|
|
|
// TestBuildShellEnv tests the buildShellEnv function
|
|
func TestBuildShellEnv(t *testing.T) {
|
|
workDir := "/test/workspace"
|
|
env := buildShellEnv(workDir)
|
|
|
|
// Check that env is not empty
|
|
if len(env) == 0 {
|
|
t.Error("expected non-empty environment")
|
|
}
|
|
|
|
// Check for PWD
|
|
pwdFound := false
|
|
termFound := false
|
|
for _, e := range env {
|
|
if e == "PWD="+workDir {
|
|
pwdFound = true
|
|
}
|
|
if e == "TERM=xterm-256color" {
|
|
termFound = true
|
|
}
|
|
}
|
|
|
|
if !pwdFound {
|
|
t.Error("expected PWD to be set in environment")
|
|
}
|
|
if !termFound {
|
|
t.Error("expected TERM to be set in environment")
|
|
}
|
|
}
|
|
|
|
// TestSessionConcurrentSubscribe tests concurrent subscribe/unsubscribe
|
|
func TestSessionConcurrentSubscribe(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
defer func() { _ = session.Stop() }()
|
|
|
|
// Concurrently subscribe and unsubscribe
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
ch := make(chan []byte, 10)
|
|
session.Subscribe(ch)
|
|
time.Sleep(10 * time.Millisecond)
|
|
session.Unsubscribe(ch)
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestSessionConcurrentWrite tests concurrent writes
|
|
func TestSessionConcurrentWrite(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
defer func() { _ = session.Stop() }()
|
|
|
|
// Concurrently write
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
_, _ = session.Write([]byte("echo test\n"))
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestSessionConcurrentStatus tests concurrent status reads
|
|
func TestSessionConcurrentStatus(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY is not supported on Windows")
|
|
}
|
|
|
|
if os.Getenv("CI") != "" {
|
|
t.Skip("Skipping PTY test in CI environment")
|
|
}
|
|
|
|
log := newTestLogger()
|
|
workDir := t.TempDir()
|
|
cfg := DefaultConfig(workDir)
|
|
|
|
session, err := NewSession(cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("NewSession failed: %v", err)
|
|
}
|
|
defer func() { _ = session.Stop() }()
|
|
|
|
// Concurrently read status
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
status := session.Status()
|
|
if !status.Running {
|
|
t.Error("expected session to be running")
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestDetectShellFallback tests shell fallback when SHELL is unset
|
|
func TestDetectShellFallback(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("SHELL env var fallback is for Unix only")
|
|
}
|
|
|
|
originalShell := os.Getenv("SHELL")
|
|
defer func() { _ = os.Setenv("SHELL", originalShell) }()
|
|
|
|
// Unset SHELL
|
|
_ = os.Unsetenv("SHELL")
|
|
|
|
shell, _ := detectShell()
|
|
|
|
// Should fall back to one of the common shells
|
|
validShells := map[string]bool{
|
|
"/bin/bash": true,
|
|
"/bin/zsh": true,
|
|
"/bin/sh": true,
|
|
}
|
|
if !validShells[shell] {
|
|
t.Logf("Detected shell: %q (may be valid if it exists on this system)", shell)
|
|
}
|
|
}
|