diff --git a/apps/backend/internal/agentctl/server/api/vscode_handlers_test.go b/apps/backend/internal/agentctl/server/api/vscode_handlers_test.go index ed9aabb85f..8d9aca4a44 100644 --- a/apps/backend/internal/agentctl/server/api/vscode_handlers_test.go +++ b/apps/backend/internal/agentctl/server/api/vscode_handlers_test.go @@ -5,8 +5,11 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "time" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/agentctl/types" ) @@ -128,7 +131,12 @@ func TestHandleVscodeOpenFile_InvalidBody(t *testing.T) { } } -func TestHandleVscodeOpenFile_NotRunning(t *testing.T) { +func TestHandleVscodeOpenFile_NotRunning_AutoStartAttempted(t *testing.T) { + // Use isolated TMPDIR so the IPC socket search finds no sockets. + t.Setenv("TMPDIR", t.TempDir()) + // Isolate HOME so ResolveBinary won't find a real code-server install. + t.Setenv("HOME", t.TempDir()) + s := newTestServer(t) body, _ := json.Marshal(types.VscodeOpenFileRequest{Path: "main.go", Line: 10, Col: 5}) @@ -138,9 +146,21 @@ func TestHandleVscodeOpenFile_NotRunning(t *testing.T) { s.router.ServeHTTP(w, req) - // Code-server is not running, should get 500 - if w.Code != http.StatusInternalServerError { - t.Errorf("expected 500, got %d", w.Code) + // Auto-start is attempted but fails (no code-server binary in isolated HOME). + // The key assertion is that auto-start was attempted: status != stopped. + info := s.procMgr.VscodeInfo() + if info.Status == "stopped" { + t.Error("expected vscode status to change from stopped (auto-start should have been attempted)") + } + + var resp types.VscodeOpenFileResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // The error should NOT be the old "is not running" message. + if !resp.Success && strings.Contains(resp.Error, "is not running") { + t.Errorf("expected auto-start to be attempted, not 'is not running'; got: %s", resp.Error) } } @@ -168,3 +188,54 @@ func TestHandleVscodeStatus_AfterStart(t *testing.T) { t.Errorf("expected installing/error/starting, got %q", resp.Status) } } + +// TestVscodeOpenFile_WaitsForRunning_E2E verifies the end-to-end flow where +// open-file is called while vscode is still starting up. The handler should +// wait for vscode to become ready before attempting to open the file. +// This uses the real HTTP handler chain: HTTP request → Gin handler → +// procMgr.VscodeOpenFile (auto-start + WaitForRunning) → VscodeManager.OpenFile. +func TestVscodeOpenFile_WaitsForRunning_E2E(t *testing.T) { + s := newTestServer(t) + ts := httptest.NewServer(s.router) + defer ts.Close() + + // Inject a VscodeManager that transitions from "installing" to "running" after 200ms. + s.procMgr.SetVscodeTransitionForTest(process.VscodeStatusInstalling, 0, 200*time.Millisecond) + + body, _ := json.Marshal(types.VscodeOpenFileRequest{Path: "main.go", Line: 1}) + + // open-file should block waiting for vscode to become running. + // It will reach "running" status, but then OpenFile will fail because + // there's no real code-server binary/IPC socket. That's expected — + // the important assertion is that WaitForRunning succeeded (no "not running" error). + resp, err := http.Post(ts.URL+"/api/v1/vscode/open-file", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + var result types.VscodeOpenFileResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // The request should fail because there's no real code-server binary, + // but the error should be about the binary/IPC socket, NOT about + // "code-server is not running" — proving WaitForRunning worked. + if result.Success { + t.Error("expected success=false (no real code-server binary)") + } + if result.Error == "" { + t.Error("expected an error message") + } + + // Crucially, the error must NOT be the old "code-server is not running" message. + if strings.Contains(result.Error, "is not running") { + t.Errorf("expected error about binary/socket, not 'is not running'; got: %s", result.Error) + } + // The error should be about the binary path or IPC socket not being found. + if !strings.Contains(result.Error, "binary") && !strings.Contains(result.Error, "remote CLI") && + !strings.Contains(result.Error, "IPC") && !strings.Contains(result.Error, "not resolved") { + t.Logf("unexpected error (test may need updating): %s", result.Error) + } +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 2e1a54380b..83090e0929 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -1146,13 +1146,36 @@ func (m *Manager) VscodeInfo() VscodeInfo { } // VscodeOpenFile opens a file in the running VS Code instance via the Remote CLI. +// If code-server is not running, it auto-starts it and waits for readiness. +// +// Note: there is a benign race between the needsStart check and WaitForRunning — +// another goroutine could stop vscode in between. WaitForRunning handles this +// correctly by returning an error for stopped/error states. func (m *Manager) VscodeOpenFile(ctx context.Context, path string, line, col int) error { + m.vscodeMu.Lock() + needsStart := m.vscode == nil + if !needsStart { + info := m.vscode.Info() + needsStart = info.Status == VscodeStatusStopped || info.Status == VscodeStatusError + } + m.vscodeMu.Unlock() + + if needsStart { + m.logger.Info("auto-starting code-server for open-file request") + m.StartVscode(ctx, "") + } + m.vscodeMu.Lock() vscode := m.vscode m.vscodeMu.Unlock() if vscode == nil { - return fmt.Errorf("code-server is not running") + return fmt.Errorf("failed to initialize code-server") } + + if err := vscode.WaitForRunning(ctx); err != nil { + return fmt.Errorf("code-server not ready: %w", err) + } + return vscode.OpenFile(ctx, path, line, col) } diff --git a/apps/backend/internal/agentctl/server/process/testing_helpers.go b/apps/backend/internal/agentctl/server/process/testing_helpers.go index 86e8f76e13..f23b82833c 100644 --- a/apps/backend/internal/agentctl/server/process/testing_helpers.go +++ b/apps/backend/internal/agentctl/server/process/testing_helpers.go @@ -1,5 +1,7 @@ package process +import "time" + // SetVscodeForTest injects a VscodeManager with the given status and port. // Intended for use in tests where starting a real code-server is not feasible. func (m *Manager) SetVscodeForTest(status VscodeStatus, port int) { @@ -10,3 +12,22 @@ func (m *Manager) SetVscodeForTest(status VscodeStatus, port int) { port: port, } } + +// SetVscodeTransitionForTest injects a VscodeManager that starts in the given +// status and transitions to VscodeStatusRunning after the specified delay. +// Useful for testing code that calls WaitForRunning. +func (m *Manager) SetVscodeTransitionForTest(initialStatus VscodeStatus, port int, delay time.Duration) { + m.vscodeMu.Lock() + defer m.vscodeMu.Unlock() + vm := &VscodeManager{ + status: initialStatus, + port: port, + } + m.vscode = vm + go func() { + time.Sleep(delay) + vm.mu.Lock() + vm.status = VscodeStatusRunning + vm.mu.Unlock() + }() +} diff --git a/apps/backend/internal/agentctl/server/process/vscode.go b/apps/backend/internal/agentctl/server/process/vscode.go index 6f2d092644..b28b10963a 100644 --- a/apps/backend/internal/agentctl/server/process/vscode.go +++ b/apps/backend/internal/agentctl/server/process/vscode.go @@ -378,6 +378,35 @@ func (v *VscodeManager) Stop(ctx context.Context) error { return nil } +// WaitForRunning blocks until the code-server reaches "running" status. +// Returns immediately if already running. Returns an error if the status +// transitions to "error" or "stopped", or if the context is cancelled. +func (v *VscodeManager) WaitForRunning(ctx context.Context) error { + for { + v.mu.Lock() + s := v.status + errMsg := v.err + v.mu.Unlock() + + switch s { + case VscodeStatusRunning: + return nil + case VscodeStatusError: + return fmt.Errorf("code-server failed to start: %s", errMsg) + case VscodeStatusStopped: + return fmt.Errorf("code-server is stopped") + case VscodeStatusInstalling, VscodeStatusStarting: + // still booting — wait and retry + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } +} + // Info returns the current VS Code server state. func (v *VscodeManager) Info() VscodeInfo { v.mu.Lock() @@ -480,7 +509,9 @@ func (v *VscodeManager) OpenFile(ctx context.Context, path string, line, col int } // Find the IPC socket for the running VS Code instance. - ipcSocket, err := findVscodeIPCSocket() + // The IPC socket may not appear immediately after code-server's HTTP port + // is ready, so poll for it with a timeout. + ipcSocket, err := waitForVscodeIPCSocket(ctx, 15*time.Second) if err != nil { return fmt.Errorf("failed to find VS Code IPC socket: %w", err) } @@ -527,6 +558,8 @@ func resolveRemoteCLI(binaryPath string) string { } // findVscodeIPCSocket searches /tmp for the most recent vscode-ipc-*.sock file. +// It validates each candidate by attempting a Unix socket connection to skip +// stale sockets left behind by crashed VS Code instances. func findVscodeIPCSocket() (string, error) { tmpDir := os.TempDir() entries, err := os.ReadDir(tmpDir) @@ -563,7 +596,42 @@ func findVscodeIPCSocket() (string, error) { return socks[i].modTime.After(socks[j].modTime) }) - return socks[0].path, nil + // Try each socket, most recent first, and return the first one that accepts + // a connection. This skips stale sockets from crashed processes. + for _, s := range socks { + conn, dialErr := net.DialTimeout("unix", s.path, 500*time.Millisecond) + if dialErr != nil { + continue + } + _ = conn.Close() + return s.path, nil + } + + return "", fmt.Errorf("no live vscode-ipc-*.sock found in %s (%d stale sockets skipped)", tmpDir, len(socks)) +} + +// waitForVscodeIPCSocket polls for a live VS Code IPC socket until one is found +// or the timeout/context expires. Code-server may take several seconds after its +// HTTP port is ready before creating the IPC socket. +func waitForVscodeIPCSocket(ctx context.Context, timeout time.Duration) (string, error) { + deadline := time.After(timeout) + var lastErr error + + for { + sock, err := findVscodeIPCSocket() + if err == nil { + return sock, nil + } + lastErr = err + + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w (last attempt: %v)", ctx.Err(), lastErr) + case <-deadline: + return "", lastErr + case <-time.After(1 * time.Second): + } + } } // setStatus updates the status under lock. diff --git a/apps/backend/internal/agentctl/server/process/vscode_test.go b/apps/backend/internal/agentctl/server/process/vscode_test.go index 70999dce60..5869b5d518 100644 --- a/apps/backend/internal/agentctl/server/process/vscode_test.go +++ b/apps/backend/internal/agentctl/server/process/vscode_test.go @@ -3,6 +3,7 @@ package process import ( "context" "encoding/json" + "net" "os" "path/filepath" "runtime" @@ -80,19 +81,23 @@ func TestFindVscodeIPCSocket_NoSockets(t *testing.T) { } func TestFindVscodeIPCSocket_ReturnsMostRecent(t *testing.T) { - tmpDir := t.TempDir() + // 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 two sock files with different mod times + // Create a stale socket file (no listener — should be skipped) sock1 := filepath.Join(tmpDir, "vscode-ipc-old.sock") - sock2 := filepath.Join(tmpDir, "vscode-ipc-new.sock") - require.NoError(t, os.WriteFile(sock1, nil, 0o600)) - // Set old mod time oldTime := time.Now().Add(-1 * time.Hour) require.NoError(t, os.Chtimes(sock1, oldTime, oldTime)) - require.NoError(t, os.WriteFile(sock2, nil, 0o600)) + // 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) @@ -112,6 +117,48 @@ func TestFindVscodeIPCSocket_IgnoresNonSockFiles(t *testing.T) { assert.Contains(t, err.Error(), "no vscode-ipc-") } +func TestWaitForVscodeIPCSocket_SocketAppearsAfterDelay(t *testing.T) { + 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) @@ -279,3 +326,77 @@ func TestVscodeInfo_JSON_OmitsEmpty(t *testing.T) { 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) +} diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index fe72304f55..c805aafa3e 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -289,4 +289,14 @@ export class SessionPage { await expect(this.files).toBeVisible({ timeout: 10_000 }); await expect(this.sidebar).toBeVisible(); } + + /** Locator for the VS Code dockview tab. */ + vscodeTab(): Locator { + return this.page.locator(".dv-default-tab:has-text('VS Code')"); + } + + /** Locator for the VS Code code-server iframe. */ + vscodeIframe(): Locator { + return this.page.locator('iframe[title="VS Code"]'); + } } diff --git a/apps/web/e2e/tests/vscode-open-file.spec.ts b/apps/web/e2e/tests/vscode-open-file.spec.ts new file mode 100644 index 0000000000..9ba1d266c6 --- /dev/null +++ b/apps/web/e2e/tests/vscode-open-file.spec.ts @@ -0,0 +1,149 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { type Page } from "@playwright/test"; +import { test, expect } from "../fixtures/test-base"; +import type { SeedData } from "../fixtures/test-base"; +import type { ApiClient } from "../helpers/api-client"; +import { SessionPage } from "../pages/session-page"; + +/** + * Resolve the code-server install directory. Checks the default kandev install + * path at ~/.kandev/tools/code-server/ (real HOME, not e2e temp HOME). + */ +function findCodeServerInstall(): string | null { + const home = os.homedir(); + const installDir = path.join(home, ".kandev", "tools", "code-server"); + if (!fs.existsSync(installDir)) return null; + + const entries = fs.readdirSync(installDir); + for (const entry of entries) { + const binPath = path.join(installDir, entry, "bin", "code-server"); + if (fs.existsSync(binPath)) return installDir; + } + return null; +} + +/** + * Seed a task + session via the API and navigate directly to the session page. + * Waits for the mock agent to complete its turn (idle input visible). + */ +async function seedTaskWithSession( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, +): Promise<{ session: SessionPage; sessionId: string }> { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await testPage.goto(`/s/${task.session_id}`); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + return { session, sessionId: task.session_id }; +} + +test.describe("VS Code open file", () => { + test.describe.configure({ retries: 1 }); + + const codeServerDir = findCodeServerInstall(); + + test.beforeEach(async ({ backend }) => { + if (!codeServerDir) { + test.skip(true, "code-server not installed — skipping VS Code e2e tests"); + return; + } + + // Symlink the real code-server install into the e2e backend's HOME so + // ResolveBinary finds the pre-installed binary without re-downloading. + const targetDir = path.join(backend.tmpDir, ".kandev", "tools", "code-server"); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.symlinkSync(codeServerDir, targetDir); + } + }); + + /** + * Full UI-driven flow: + * 1. Create a file in the workspace + * 2. Click the file in the file tree → opens in built-in editor + * 3. Click "Open with..." → "VS Code (Embedded)" in the editor toolbar + * 4. Assert VS Code panel opens, code-server starts, and iframe loads + */ + test("opens file in VS Code via Open With menu", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + // Create a file in the workspace repo BEFORE navigating so the file tree + // picks it up on its initial load. + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const filePath = path.join(repoDir, "hello.txt"); + fs.writeFileSync(filePath, "Hello from e2e test!\n"); + + const { session } = await seedTaskWithSession( + testPage, + apiClient, + seedData, + "VSCode Open File Test", + ); + + // Click the "Files" tab to ensure the file tree is visible + await session.clickTab("Files"); + await expect(session.files).toBeVisible({ timeout: 5_000 }); + + // Wait for the file tree to load and show our file + const fileRow = session.files.getByText("hello.txt"); + await expect(fileRow).toBeVisible({ timeout: 10_000 }); + + // Click the file → opens in the built-in Monaco editor panel + await fileRow.click(); + + // Wait for the file editor tab to appear in dockview + const editorTab = testPage.locator(".dv-default-tab:has-text('hello.txt')"); + await expect(editorTab).toBeVisible({ timeout: 10_000 }); + + // Click the "Open with..." button (IconExternalLink) in the Monaco editor toolbar. + // Tabler icons render with class "tabler-icon tabler-icon-external-link". + // Find the button that wraps this icon (the FileActionsDropdown trigger). + const openWithBtn = testPage.locator("button:has(.tabler-icon-external-link)").first(); + await openWithBtn.waitFor({ state: "visible", timeout: 10_000 }); + await openWithBtn.click(); + + // Click "VS Code (Embedded)" in the dropdown menu + const vsCodeMenuItem = testPage.getByRole("menuitem", { name: /VS Code/ }).first(); + await expect(vsCodeMenuItem).toBeVisible({ timeout: 5_000 }); + await vsCodeMenuItem.click(); + + // Assert: VS Code tab appears in dockview + await expect(session.vscodeTab()).toBeVisible({ timeout: 10_000 }); + + // Assert: "Starting VS Code Server" progress text is visible while booting + await expect(testPage.getByText("Starting VS Code Server")).toBeVisible({ timeout: 30_000 }); + + // Assert: code-server iframe loads (code-server is running) + await expect(session.vscodeIframe()).toBeVisible({ timeout: 90_000 }); + + // Assert: hello.txt is opened inside code-server (visible as a tab or in the editor). + // The iframe is same-origin (served via kandev proxy) so frameLocator works. + const vscodeFrame = testPage.frameLocator('iframe[title="VS Code"]'); + await expect(vscodeFrame.getByRole("tab", { name: /hello\.txt/ })).toBeVisible({ + timeout: 30_000, + }); + }); +});