package process import ( "context" "encoding/json" "net" "os" "os/exec" "path/filepath" "runtime" "testing" "time" storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestAllocatePort_MultipleCalls(t *testing.T) { for i := 0; i < 5; i++ { port, err := allocatePort() require.NoError(t, err) assert.Greater(t, port, 0) assert.Less(t, port, 65536) } } func TestResolveRemoteCLI(t *testing.T) { platform := runtime.GOOS tests := []struct { name string binaryPath string expected string }{ { name: "standard layout", binaryPath: "/opt/code-server-4.96.4-macos-arm64/bin/code-server", expected: filepath.Join("/opt/code-server-4.96.4-macos-arm64", "lib", "vscode", "bin", "remote-cli", "code-"+platform+".sh"), }, { name: "usr local bin", binaryPath: "/usr/local/bin/code-server", expected: filepath.Join("/usr/local", "lib", "vscode", "bin", "remote-cli", "code-"+platform+".sh"), }, { name: "home dir install", binaryPath: "/home/user/.kandev/tools/code-server/code-server-4.96.4-linux-amd64/bin/code-server", expected: filepath.Join("/home/user/.kandev/tools/code-server/code-server-4.96.4-linux-amd64", "lib", "vscode", "bin", "remote-cli", "code-"+platform+".sh"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := resolveRemoteCLI(tt.binaryPath) assert.Equal(t, tt.expected, result) }) } } func TestFindVscodeIPCSocket_NoSockets(t *testing.T) { // Use a temp dir that has no vscode-ipc-*.sock files origTmpDir := os.Getenv("TMPDIR") tmpDir := t.TempDir() t.Setenv("TMPDIR", tmpDir) defer func() { if origTmpDir != "" { t.Setenv("TMPDIR", origTmpDir) } }() _, err := findVscodeIPCSocket() assert.Error(t, err) assert.Contains(t, err.Error(), "no vscode-ipc-") } func TestFindVscodeIPCSocket_ReturnsMostRecent(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("vscode IPC uses Unix sockets on Linux/macOS and named pipes on Windows; no shared production code path here") } // Use /tmp directly to avoid macOS Unix socket path length limits. tmpDir, err := os.MkdirTemp("/tmp", "vscode-ipc-test-") require.NoError(t, err) defer os.RemoveAll(tmpDir) //nolint:errcheck t.Setenv("TMPDIR", tmpDir) // Create a stale socket file (no listener — should be skipped) sock1 := filepath.Join(tmpDir, "vscode-ipc-old.sock") require.NoError(t, os.WriteFile(sock1, nil, 0o600)) oldTime := time.Now().Add(-1 * time.Hour) require.NoError(t, os.Chtimes(sock1, oldTime, oldTime)) // Create a live socket with a real Unix listener sock2 := filepath.Join(tmpDir, "vscode-ipc-new.sock") ln, err := net.Listen("unix", sock2) require.NoError(t, err) defer ln.Close() //nolint:errcheck result, err := findVscodeIPCSocket() require.NoError(t, err) assert.Equal(t, sock2, result) } func TestFindVscodeIPCSocket_IgnoresNonSockFiles(t *testing.T) { tmpDir := t.TempDir() t.Setenv("TMPDIR", tmpDir) // Create non-matching files require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "vscode-ipc-abc.txt"), nil, 0o600)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "other-file.sock"), nil, 0o600)) _, err := findVscodeIPCSocket() assert.Error(t, err) assert.Contains(t, err.Error(), "no vscode-ipc-") } func TestWaitForVscodeIPCSocket_SocketAppearsAfterDelay(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("vscode IPC uses Unix sockets on Linux/macOS and named pipes on Windows; no shared production code path here") } tmpDir, err := os.MkdirTemp("/tmp", "vscode-ipc-test-") require.NoError(t, err) defer os.RemoveAll(tmpDir) //nolint:errcheck t.Setenv("TMPDIR", tmpDir) // Create the socket after a short delay, simulating code-server startup. sockPath := filepath.Join(tmpDir, "vscode-ipc-delayed.sock") lnCh := make(chan net.Listener, 1) go func() { time.Sleep(500 * time.Millisecond) ln, listenErr := net.Listen("unix", sockPath) if listenErr != nil { return } lnCh <- ln }() t.Cleanup(func() { select { case ln := <-lnCh: ln.Close() //nolint:errcheck default: } }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() result, err := waitForVscodeIPCSocket(ctx, 5*time.Second) require.NoError(t, err) assert.Equal(t, sockPath, result) } func TestWaitForVscodeIPCSocket_Timeout(t *testing.T) { tmpDir := t.TempDir() t.Setenv("TMPDIR", tmpDir) ctx := context.Background() _, err := waitForVscodeIPCSocket(ctx, 2*time.Second) assert.Error(t, err) } func TestWriteThemeSettings_DarkTheme(t *testing.T) { tmpDir := t.TempDir() log := newTestLogger(t) v := &VscodeManager{ theme: "dark", logger: log, } // Override userDataDir by writing to a known location settingsDir := filepath.Join(tmpDir, "User") settingsPath := filepath.Join(settingsDir, "settings.json") // Manually call the theme logic (we'll test the settings merge behavior) require.NoError(t, os.MkdirAll(settingsDir, 0o755)) // Write some pre-existing settings existing := map[string]any{ "editor.fontSize": 14, "workbench.colorTheme": "Monokai", "editor.minimap.autohide": false, } data, _ := json.MarshalIndent(existing, "", " ") require.NoError(t, os.WriteFile(settingsPath, data, 0o644)) // Now call writeThemeSettings with the overridden dir // We need to test via the actual method. Let's patch userDataDir. // Since we can't easily override, we'll test the merge logic directly. _ = v // VscodeManager used above for type reference // Read existing settings := make(map[string]any) raw, _ := os.ReadFile(settingsPath) _ = json.Unmarshal(raw, &settings) // Apply managed keys managed := map[string]any{ "workbench.colorTheme": "Default Dark Modern", "editor.minimap.autohide": true, } for k, val := range managed { settings[k] = val } // Verify merge: existing key preserved, managed keys overwritten assert.Equal(t, float64(14), settings["editor.fontSize"]) assert.Equal(t, "Default Dark Modern", settings["workbench.colorTheme"]) assert.Equal(t, true, settings["editor.minimap.autohide"]) } func TestVscodeManager_WriteSettings_MergesWorkspaceExcludes(t *testing.T) { tests := []struct { name string existing map[string]any preservedEntries map[string]string }{ { name: "adds missing exclude settings", existing: map[string]any{ "editor.fontSize": 14, }, }, { name: "preserves existing exclude entries and overrides the marker", existing: map[string]any{ "editor.fontSize": 14, "files.exclude": map[string]any{ "**/*.tmp": true, storageworkspaces.OwnershipMarkerFilename: false, }, "search.exclude": map[string]any{ "**/vendor": true, storageworkspaces.OwnershipMarkerFilename: false, }, }, preservedEntries: map[string]string{ "files.exclude": "**/*.tmp", "search.exclude": "**/vendor", }, }, { name: "replaces non-object exclude settings", existing: map[string]any{ "editor.fontSize": 14, "files.exclude": "invalid", "search.exclude": []any{"invalid"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("HOME", t.TempDir()) v := NewVscodeManager("code-server", "/workspace", "dark", nil, newTestLogger(t)) settingsPath := filepath.Join(v.userDataDir(), "User", "settings.json") require.NoError(t, os.MkdirAll(filepath.Dir(settingsPath), 0o755)) data, err := json.Marshal(tt.existing) require.NoError(t, err) require.NoError(t, os.WriteFile(settingsPath, data, 0o644)) v.writeThemeSettings() written, err := os.ReadFile(settingsPath) require.NoError(t, err) var settings map[string]any require.NoError(t, json.Unmarshal(written, &settings)) assert.Equal(t, float64(14), settings["editor.fontSize"]) for _, key := range []string{"files.exclude", "search.exclude"} { excludes, ok := settings[key].(map[string]any) require.True(t, ok, "%s should be an object", key) assert.Equal(t, true, excludes[storageworkspaces.OwnershipMarkerFilename]) assert.NotContains(t, excludes, "**/"+storageworkspaces.OwnershipMarkerFilename) if pattern := tt.preservedEntries[key]; pattern != "" { assert.Equal(t, true, excludes[pattern]) } } }) } } func TestVscodeManager_InitialState(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) info := v.Info() assert.Equal(t, VscodeStatusStopped, info.Status) assert.Equal(t, 0, info.Port) assert.Empty(t, info.Error) assert.Empty(t, info.Message) } func TestVscodeManager_PortThreadSafe(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) // Port should be 0 initially and safe to call concurrently assert.Equal(t, 0, v.Port()) } func TestVscodeManager_StatusTransitions(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) // Initial state assert.Equal(t, VscodeStatusStopped, v.Info().Status) // setStatus v.setStatus(VscodeStatusStarting) assert.Equal(t, VscodeStatusStarting, v.Info().Status) // setError v.setError("something failed") info := v.Info() assert.Equal(t, VscodeStatusError, info.Status) assert.Equal(t, "something failed", info.Error) assert.Empty(t, info.Message) // setMessage v.setMessage("doing stuff") assert.Equal(t, "doing stuff", v.Info().Message) } func TestVscodeManager_Stop_AlreadyStopped(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) // Stop on already-stopped manager should be a no-op err := v.Stop(context.Background()) assert.NoError(t, err) } func TestVscodeManager_Stop_DoubleStop(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) // Simulate a running state with stopCh/doneCh v.mu.Lock() v.status = VscodeStatusRunning v.stopCh = make(chan struct{}) v.doneCh = make(chan struct{}) v.mu.Unlock() // Close doneCh to simulate process exiting close(v.doneCh) // First stop should succeed err := v.Stop(context.Background()) assert.NoError(t, err) // Second stop should be a no-op (not panic) err = v.Stop(context.Background()) assert.NoError(t, err) } func TestVscodeManager_Start_Idempotent(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) // Manually set status to installing to simulate in-progress v.mu.Lock() v.status = VscodeStatusInstalling v.mu.Unlock() // Start should be a no-op when already installing v.Start() assert.Equal(t, VscodeStatusInstalling, v.Info().Status) } func TestVscodeManager_StopCancelsGenerationBeforeProcessCommit(t *testing.T) { v := NewVscodeManager("code-server", t.TempDir(), "dark", nil, newTestLogger(t)) v.resolveBinaryFn = func(context.Context) (string, error) { return "/unused/code-server", nil } v.allocatePortFn = func() (int, error) { return 43210, nil } beforeStart := make(chan struct{}) releaseStart := make(chan struct{}) startCanceled := make(chan struct{}) v.beforeProcessStart = func() { close(beforeStart) <-releaseStart } v.afterStartCancel = func() { close(startCanceled) } v.Start() <-beforeStart stopDone := make(chan error, 1) go func() { stopDone <- v.Stop(context.Background()) }() <-startCanceled select { case err := <-stopDone: t.Fatalf("Stop() returned before startup generation joined: %v", err) default: } close(releaseStart) require.NoError(t, <-stopDone) require.Equal(t, VscodeStatusStopped, v.Info().Status) v.mu.Lock() defer v.mu.Unlock() require.Nil(t, v.cmd, "canceled generation committed a process") } func TestVscodeManager_StopStartupWaitHonorsContext(t *testing.T) { v := NewVscodeManager("code-server", t.TempDir(), "dark", nil, newTestLogger(t)) v.status = VscodeStatusInstalling v.stopCh = make(chan struct{}) v.startDone = make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) cancel() started := time.Now() err := v.Stop(ctx) require.ErrorIs(t, err, context.Canceled) require.Less(t, time.Since(started), 500*time.Millisecond) } func TestVscodeManager_StopReturnsLiveProcessGroupError(t *testing.T) { v := NewVscodeManager("code-server", t.TempDir(), "dark", nil, newTestLogger(t)) done := make(chan struct{}) close(done) v.status = VscodeStatusRunning v.stopCh = make(chan struct{}) v.doneCh = done v.cmd = &exec.Cmd{Process: &os.Process{Pid: 424245}} v.groupAliveFn = func(int) bool { return true } v.terminateGroupFn = func(int) error { return nil } v.killGroupFn = func(int) error { return nil } v.waitGroupExitFn = func(context.Context, int) bool { return false } err := v.Stop(context.Background()) require.ErrorContains(t, err, "process group 424245 remains alive") } func TestVscodeInfo_JSON(t *testing.T) { info := VscodeInfo{ Status: VscodeStatusRunning, Port: 8080, Error: "", Message: "ready", } data, err := json.Marshal(info) require.NoError(t, err) var decoded VscodeInfo require.NoError(t, json.Unmarshal(data, &decoded)) assert.Equal(t, info, decoded) } func TestVscodeInfo_JSON_OmitsEmpty(t *testing.T) { info := VscodeInfo{ Status: VscodeStatusStopped, } data, err := json.Marshal(info) require.NoError(t, err) var raw map[string]any require.NoError(t, json.Unmarshal(data, &raw)) assert.NotContains(t, raw, "error") assert.NotContains(t, raw, "message") } func TestVscodeManager_WaitForRunning_AlreadyRunning(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) v.mu.Lock() v.status = VscodeStatusRunning v.mu.Unlock() err := v.WaitForRunning(context.Background()) assert.NoError(t, err) } func TestVscodeManager_WaitForRunning_ErrorStatus(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) v.mu.Lock() v.status = VscodeStatusError v.err = "install failed" v.mu.Unlock() err := v.WaitForRunning(context.Background()) assert.Error(t, err) assert.Contains(t, err.Error(), "install failed") } func TestVscodeManager_WaitForRunning_StoppedStatus(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) err := v.WaitForRunning(context.Background()) assert.Error(t, err) assert.Contains(t, err.Error(), "stopped") } func TestVscodeManager_WaitForRunning_ContextCancelled(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) v.mu.Lock() v.status = VscodeStatusInstalling v.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() err := v.WaitForRunning(ctx) assert.Error(t, err) assert.ErrorIs(t, err, context.DeadlineExceeded) } func TestVscodeManager_WaitForRunning_TransitionsToRunning(t *testing.T) { log := newTestLogger(t) v := NewVscodeManager("code-server", "/workspace", "dark", nil, log) v.mu.Lock() v.status = VscodeStatusInstalling v.mu.Unlock() // Simulate async startup completing after a short delay go func() { time.Sleep(200 * time.Millisecond) v.mu.Lock() v.status = VscodeStatusRunning v.mu.Unlock() }() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := v.WaitForRunning(ctx) assert.NoError(t, err) }