546 lines
15 KiB
Go
546 lines
15 KiB
Go
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(t *testing.T) {
|
|
port, err := allocatePort()
|
|
require.NoError(t, err)
|
|
assert.Greater(t, port, 0)
|
|
assert.Less(t, port, 65536)
|
|
}
|
|
|
|
func TestAllocatePort_Unique(t *testing.T) {
|
|
ports := make(map[int]bool)
|
|
for i := 0; i < 5; i++ {
|
|
port, err := allocatePort()
|
|
require.NoError(t, err)
|
|
assert.False(t, ports[port], "port %d allocated twice", port)
|
|
ports[port] = true
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|