package process import ( "context" "strings" "testing" "time" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" ) func newTestLogger(t *testing.T) *logger.Logger { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", Format: "json", }) if err != nil { t.Fatalf("failed to create logger: %v", err) } return log } func newObservedTestLogger(t *testing.T) (*logger.Logger, *observer.ObservedLogs) { t.Helper() core, observed := observer.New(zapcore.DebugLevel) log, err := logger.NewFromZap(zap.New(core)) if err != nil { t.Fatalf("failed to create observed logger: %v", err) } return log, observed } func observedLogsContain(logs *observer.ObservedLogs, message string) bool { for _, entry := range logs.All() { if entry.Message == message { return true } } return false } func TestRingBufferTrimsOldest(t *testing.T) { buffer := newRingBuffer(10) buffer.append(ProcessOutputChunk{Stream: "stdout", Data: "hello", Timestamp: time.Now()}) // 5 buffer.append(ProcessOutputChunk{Stream: "stdout", Data: "world", Timestamp: time.Now()}) // 5 (total 10) buffer.append(ProcessOutputChunk{Stream: "stderr", Data: "!!!", Timestamp: time.Now()}) // +3 -> trim snapshot := buffer.snapshot() if len(snapshot) == 0 { t.Fatal("expected buffered output") } combined := "" for _, chunk := range snapshot { combined += chunk.Data } if strings.Contains(combined, "hello") { t.Fatalf("expected oldest chunk to be trimmed, got %q", combined) } if !strings.Contains(combined, "world") { t.Fatalf("expected newer chunk to remain, got %q", combined) } } func TestProcessRunnerCapturesOutput(t *testing.T) { log := newTestLogger(t) runner := NewProcessRunner(nil, log, 2*1024*1024) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd, env := fixtureShellExec("echo-then-sleep hello 2") info, err := runner.Start(ctx, StartProcessRequest{ SessionID: "session-1", Kind: "dev", Command: cmd, Env: env, WorkingDir: "", }) if err != nil { t.Fatalf("failed to start process: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { proc, ok := runner.Get(info.ID, true) if !ok { time.Sleep(25 * time.Millisecond) continue } combined := "" for _, chunk := range proc.Output { combined += chunk.Data } if strings.Contains(combined, "hello") { return } time.Sleep(25 * time.Millisecond) } t.Fatal("process output not captured in time") } func TestProcessRunnerStopLogsSignalAttempts(t *testing.T) { log, observed := newObservedTestLogger(t) runner := NewProcessRunner(nil, log, 2*1024*1024) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd, env := fixtureShellExec("sleep 30") info, err := runner.Start(ctx, StartProcessRequest{ SessionID: "session-1", Kind: "dev", Command: cmd, Env: env, WorkingDir: "", }) if err != nil { t.Fatalf("failed to start process: %v", err) } t.Cleanup(func() { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) defer cleanupCancel() _ = runner.Stop(cleanupCtx, StopProcessRequest{ProcessID: info.ID}) }) stopCtx, stopCancel := context.WithCancel(context.Background()) stopCancel() if err := runner.Stop(stopCtx, StopProcessRequest{ProcessID: info.ID}); err != nil { t.Fatalf("failed to stop process: %v", err) } for _, message := range []string{ "workspace process stop requested", "workspace process interrupt requested", "workspace process group SIGKILL requested", } { if !observedLogsContain(observed, message) { t.Fatalf("expected debug log %q, got %#v", message, observed.All()) } } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if _, ok := runner.Get(info.ID, false); !ok { return } time.Sleep(20 * time.Millisecond) } t.Fatal("process was not removed after stop") } func TestStripANSI(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "no ANSI codes", input: "hello world", expected: "hello world", }, { name: "empty string", input: "", expected: "", }, { name: "simple color code", input: "\x1b[31mred text\x1b[0m", expected: "red text", }, { name: "bold text", input: "\x1b[1mbold\x1b[0m", expected: "bold", }, { name: "multiple colors", input: "\x1b[31mred\x1b[32mgreen\x1b[34mblue\x1b[0m", expected: "redgreenblue", }, { name: "256 color code", input: "\x1b[38;5;196mcolored\x1b[0m", expected: "colored", }, { name: "RGB color code", input: "\x1b[38;2;255;0;0mred\x1b[0m", expected: "red", }, { name: "cursor movement", input: "\x1b[2Amove up\x1b[3Bmove down", expected: "move upmove down", }, { name: "clear line", input: "text\x1b[2Kcleared", expected: "textcleared", }, { name: "real world npm output", input: "\x1b[32m✓\x1b[39m \x1b[90mCompiled successfully\x1b[39m", expected: "✓ Compiled successfully", }, { name: "mixed with newlines", input: "\x1b[31mError:\x1b[0m\nSomething failed\n\x1b[33mWarning:\x1b[0m check logs", expected: "Error:\nSomething failed\nWarning: check logs", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := stripANSI(tt.input) if result != tt.expected { t.Errorf("stripANSI(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } func TestIsNpmEnvVar(t *testing.T) { tests := []struct { key string expected bool }{ // Should filter {"npm_config_registry", true}, {"npm_config_npm-globalconfig", true}, {"npm_config__jsr-registry", true}, {"npm_config_verify-deps-before-run", true}, {"npm_config_dir", true}, {"npm_package_name", true}, {"npm_package_version", true}, {"npm_lifecycle_event", true}, {"npm_execpath", true}, {"npm_node_execpath", true}, // Should keep {"PATH", false}, {"HOME", false}, {"USER", false}, {"SHELL", false}, {"ANTHROPIC_API_KEY", false}, {"NPM_TOKEN", false}, // Uppercase, different format {"NPMRC", false}, // Not npm_ prefix {"npm_not_a_config", false}, // Doesn't match any prefix } for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { result := isNpmEnvVar(tt.key) if result != tt.expected { t.Errorf("isNpmEnvVar(%q) = %v, want %v", tt.key, result, tt.expected) } }) } } func TestMergeEnv_FiltersNpmVars(t *testing.T) { // Set some test npm env vars that should be filtered t.Setenv("npm_config_test_var", "should_be_filtered") t.Setenv("npm_package_name", "test-package") // Set a normal var that should be kept t.Setenv("TEST_NORMAL_VAR", "should_be_kept") result := mergeEnv(map[string]string{ "CUSTOM_VAR": "custom_value", }) // Convert to map for easier checking resultMap := make(map[string]string) for _, entry := range result { if eq := strings.IndexByte(entry, '='); eq >= 0 { resultMap[entry[:eq]] = entry[eq+1:] } } // Check that npm vars are filtered if _, exists := resultMap["npm_config_test_var"]; exists { t.Error("npm_config_test_var should have been filtered") } if _, exists := resultMap["npm_package_name"]; exists { t.Error("npm_package_name should have been filtered") } // Check that normal vars are kept if resultMap["TEST_NORMAL_VAR"] != "should_be_kept" { t.Error("TEST_NORMAL_VAR should have been kept") } // Check that custom vars are added if resultMap["CUSTOM_VAR"] != "custom_value" { t.Error("CUSTOM_VAR should have been added") } }