333 lines
8.6 KiB
Go
333 lines
8.6 KiB
Go
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")
|
|
}
|
|
}
|
|
|
|
func TestMergeEnvWithStrip_RemovesDeclaredParentAndCustomVars(t *testing.T) {
|
|
t.Setenv("ACP_BACKEND", "windsurf")
|
|
t.Setenv("TEST_KEEP_ME", "yes")
|
|
|
|
result := mergeEnvWithStrip(map[string]string{
|
|
"ACP_BACKEND": "custom",
|
|
"CUSTOM_VAR": "custom_value",
|
|
}, []string{"ACP_BACKEND"})
|
|
|
|
resultMap := envSliceToMap(result)
|
|
if _, exists := resultMap["ACP_BACKEND"]; exists {
|
|
t.Fatalf("ACP_BACKEND should have been stripped, got %q", resultMap["ACP_BACKEND"])
|
|
}
|
|
if resultMap["TEST_KEEP_ME"] != "yes" {
|
|
t.Fatalf("TEST_KEEP_ME should have been kept, got %q", resultMap["TEST_KEEP_ME"])
|
|
}
|
|
if resultMap["CUSTOM_VAR"] != "custom_value" {
|
|
t.Fatalf("CUSTOM_VAR should have been added, got %q", resultMap["CUSTOM_VAR"])
|
|
}
|
|
}
|
|
|
|
func envSliceToMap(env []string) map[string]string {
|
|
out := make(map[string]string)
|
|
for _, entry := range env {
|
|
if eq := strings.IndexByte(entry, '='); eq >= 0 {
|
|
out[entry[:eq]] = entry[eq+1:]
|
|
}
|
|
}
|
|
return out
|
|
}
|