fix: auto-start code-server when opening file via VS Code (#368)
fix: correctly open file with vscode if vscode is not started yet
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"]');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user