From 116895b4ff067fdfbcd2f59eea4e51e9680802f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 25 Mar 2026 23:00:05 +0000 Subject: [PATCH] fix: resolve symlinks in file tree so symlink-to-directory entries show as folders (#467) --- apps/backend/cmd/mock-agent/scenarios.go | 43 +++++ .../server/process/workspace_files.go | 11 +- .../server/process/workspace_files_test.go | 99 ++++++++++ .../internal/agentctl/types/streams/file.go | 3 + apps/web/e2e/tests/symlink-file.spec.ts | 181 ++++++++++++++++++ apps/web/lib/types/workspace-files.ts | 1 + 6 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 apps/web/e2e/tests/symlink-file.spec.ts diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index 8c0c9f2d78..df8064de90 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -30,6 +30,7 @@ var scenarioRegistry = map[string]func(e *emitter){ "clarification": scenarioClarification, "clarification-timeout": scenarioClarificationTimeout, "review-cumulative-setup": scenarioReviewCumulativeSetup, + "symlink-file-setup": scenarioSymlinkFileSetup, } // emitPredefinedScenario dispatches to a named e2e scenario. @@ -501,6 +502,48 @@ func scenarioReviewCumulativeSetup(e *emitter) { e.text("review-cumulative-setup complete: " + filePath + " has COMMITTED_CHANGE and UNCOMMITTED_CHANGE") } +// scenarioSymlinkFileSetup creates a file and a symlink to it, commits both, +// then modifies the target file leaving an uncommitted diff. +func scenarioSymlinkFileSetup(e *emitter) { + fixedDelay(50) + + wd, err := os.Getwd() + if err != nil { + e.text("symlink-file-setup: getwd failed: " + err.Error()) + return + } + + runGitCmd := makeGitRunner(wd) + + // Create target file and commit it. + targetFile := "real-file.txt" + if err := os.WriteFile(targetFile, []byte("Hello from symlink target!\n"), 0o644); err != nil { + e.text("symlink-file-setup: write target failed: " + err.Error()) + return + } + _ = runGitCmd("add", targetFile) + _ = runGitCmd("commit", "-m", "add real-file.txt") + + // Create symlink and commit it. + linkFile := "link-file.txt" + _ = os.Remove(linkFile) // clean up if leftover + if err := os.Symlink(targetFile, linkFile); err != nil { + e.text("symlink-file-setup: symlink failed: " + err.Error()) + return + } + _ = runGitCmd("add", linkFile) + _ = runGitCmd("commit", "-m", "add link-file.txt symlink") + + // Modify target file, leaving an uncommitted diff. + if err := os.WriteFile(targetFile, []byte("Modified symlink target content!\n"), 0o644); err != nil { + e.text("symlink-file-setup: write modified failed: " + err.Error()) + return + } + + fixedDelay(100) + e.text("symlink-file-setup complete") +} + // makeGitRunner returns a function that runs git commands in the given directory. func makeGitRunner(wd string) func(args ...string) error { gitEnv := append(os.Environ(), diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index a1067bb82b..ce0256462b 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -133,7 +133,15 @@ func (wt *WorkspaceTracker) buildFileTreeNode(safePath, relPath string, info os. childFullPath := filepath.Join(safePath, name) childRelPath := filepath.Join(relPath, name) - childInfo, err := entry.Info() + isSymlink := entry.Type()&os.ModeSymlink != 0 + var childInfo os.FileInfo + if isSymlink { + // Follow symlink to get target's info (IsDir, Size). + // os.Stat returns ELOOP for circular symlinks, which we skip. + childInfo, err = os.Stat(childFullPath) + } else { + childInfo, err = entry.Info() + } if err != nil { continue } @@ -142,6 +150,7 @@ func (wt *WorkspaceTracker) buildFileTreeNode(safePath, relPath string, info os. if err != nil { continue } + childNode.IsSymlink = isSymlink node.Children = append(node.Children, childNode) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_files_test.go b/apps/backend/internal/agentctl/server/process/workspace_files_test.go index cea21beb50..f9012f6727 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/kandev/kandev/internal/agentctl/types" ) func TestResolveNonExistentPath(t *testing.T) { @@ -113,3 +115,100 @@ func TestResolveNonExistentPath(t *testing.T) { } }) } + +// requireChild finds a child node by name in the tree, failing the test if not found. +func requireChild(t *testing.T, node *types.FileTreeNode, name string) *types.FileTreeNode { + t.Helper() + for _, c := range node.Children { + if c.Name == name { + return c + } + } + t.Fatalf("%s not found in tree children", name) + return nil // unreachable, but satisfies staticcheck +} + +func findChild(node *types.FileTreeNode, name string) *types.FileTreeNode { + for _, c := range node.Children { + if c.Name == name { + return c + } + } + return nil +} + +func TestGetFileTree_Symlinks(t *testing.T) { + tmpDir := t.TempDir() + wt := &WorkspaceTracker{workDir: tmpDir} + + t.Run("symlink to file shows as file with IsSymlink", func(t *testing.T) { + content := []byte("target content") + if err := os.WriteFile(filepath.Join(tmpDir, "target.txt"), content, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target.txt", filepath.Join(tmpDir, "link.txt")); err != nil { + t.Skip("symlinks not supported") + } + + tree, err := wt.GetFileTree("", 1) + if err != nil { + t.Fatalf("GetFileTree failed: %v", err) + } + + node := requireChild(t, tree, "link.txt") + if node.IsDir { + t.Error("symlink to file should not be a directory") + } + if !node.IsSymlink { + t.Error("symlink entry should have IsSymlink=true") + } + if node.Size != int64(len(content)) { + t.Errorf("size = %d, want %d", node.Size, len(content)) + } + }) + + t.Run("symlink to directory shows as directory with IsSymlink", func(t *testing.T) { + realDir := filepath.Join(tmpDir, "realdir") + if err := os.Mkdir(realDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "child.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("realdir", filepath.Join(tmpDir, "linkdir")); err != nil { + t.Skip("symlinks not supported") + } + + tree, err := wt.GetFileTree("", 2) + if err != nil { + t.Fatalf("GetFileTree failed: %v", err) + } + + node := requireChild(t, tree, "linkdir") + if !node.IsDir { + t.Error("symlink to directory should have IsDir=true") + } + if !node.IsSymlink { + t.Error("symlink entry should have IsSymlink=true") + } + child := findChild(node, "child.txt") + if child == nil { + t.Error("child.txt not found inside symlinked directory") + } + }) + + t.Run("broken symlink is skipped", func(t *testing.T) { + if err := os.Symlink("/nonexistent-target", filepath.Join(tmpDir, "broken")); err != nil { + t.Skip("symlinks not supported") + } + + tree, err := wt.GetFileTree("", 1) + if err != nil { + t.Fatalf("GetFileTree failed: %v", err) + } + + if findChild(tree, "broken") != nil { + t.Error("broken symlink should be skipped in tree") + } + }) +} diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index 7bb944bb8a..e156e2430a 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -75,6 +75,9 @@ type FileTreeNode struct { // Size is the file size in bytes (for files only). Size int64 `json:"size,omitempty"` + // IsSymlink indicates this entry is a symbolic link. + IsSymlink bool `json:"is_symlink,omitempty"` + // Children contains child nodes (for directories only). Children []*FileTreeNode `json:"children,omitempty"` } diff --git a/apps/web/e2e/tests/symlink-file.spec.ts b/apps/web/e2e/tests/symlink-file.spec.ts new file mode 100644 index 0000000000..0d4e747cb9 --- /dev/null +++ b/apps/web/e2e/tests/symlink-file.spec.ts @@ -0,0 +1,181 @@ +import fs from "node:fs"; +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"; + +/** + * Seed a task with the simple-message scenario and navigate to its session page. + */ +async function seedSimpleTask( + 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 }; +} + +/** + * Seed a task using the symlink-file-setup mock scenario. The scenario creates + * real-file.txt, a symlink link-file.txt → real-file.txt, commits both, then + * modifies real-file.txt leaving an uncommitted diff. + */ +async function seedSymlinkDiffTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Symlink Diff E2E", + seedData.agentProfileId, + { + description: "/e2e:symlink-file-setup", + 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.chat.getByText("symlink-file-setup complete", { exact: false })).toBeVisible( + { timeout: 45_000 }, + ); + + return session; +} + +test.describe("Symlink file handling", () => { + test.describe.configure({ retries: 1 }); + + test("symlink to a directory appears as expandable folder in the file tree", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + // Create a directory with a child file, and a symlink pointing to it. + // BEFORE navigating so the file tree picks it up on initial load. + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + + fs.mkdirSync(path.join(repoDir, "real-dir"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "real-dir", "child.txt"), "inside symlinked dir\n"); + fs.symlinkSync("real-dir", path.join(repoDir, "link-dir")); + + const { session } = await seedSimpleTask( + testPage, + apiClient, + seedData, + "Symlink Dir Tree Test", + ); + + // Open Files tab + await session.clickTab("Files"); + await expect(session.files).toBeVisible({ timeout: 5_000 }); + + // The symlink-to-directory should appear in the tree + const linkDirRow = session.files.getByText("link-dir"); + await expect(linkDirRow).toBeVisible({ timeout: 10_000 }); + + // Click to expand — if the fix is missing, this is classified as a file + // and would try to open it in the editor instead of expanding. + await linkDirRow.click(); + + // Assert the child file inside the symlinked directory is now visible + const childFile = session.files.getByText("child.txt"); + await expect(childFile).toBeVisible({ timeout: 10_000 }); + }); + + test("clicking a symlink to a file opens the file editor", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + + fs.writeFileSync(path.join(repoDir, "real-file.txt"), "Hello from symlink target!\n"); + fs.symlinkSync("real-file.txt", path.join(repoDir, "link-file.txt")); + + const { session } = await seedSimpleTask( + testPage, + apiClient, + seedData, + "Symlink File Tree Test", + ); + + // Open Files tab and click the symlink + await session.clickTab("Files"); + await expect(session.files).toBeVisible({ timeout: 5_000 }); + + const fileRow = session.files.getByText("link-file.txt"); + await expect(fileRow).toBeVisible({ timeout: 10_000 }); + await fileRow.click(); + + // Assert editor tab opens for the symlink + const editorTab = testPage.locator(".dv-default-tab:has-text('link-file.txt')"); + await expect(editorTab).toBeVisible({ timeout: 10_000 }); + + // Assert the file content is visible in the Monaco editor + const editorContent = testPage.locator(".view-lines"); + await expect(editorContent).toContainText("Hello from symlink target", { + timeout: 10_000, + }); + }); + + test("clicking a symlink target diff in the Changes panel opens the diff viewer", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedSymlinkDiffTask(testPage, apiClient, seedData); + + // Open Changes tab + const changesTab = testPage.locator(".dv-default-tab", { hasText: "Changes" }); + await expect(changesTab).toBeVisible({ timeout: 10_000 }); + await changesTab.click(); + + // Click real-file.txt in the changes list (has uncommitted diff) + const fileRow = testPage + .locator("button, [role='button'], [class*='file']") + .filter({ hasText: "real-file.txt" }) + .first(); + await expect(fileRow).toBeVisible({ timeout: 10_000 }); + await fileRow.click(); + + // Assert Pierre Diffs viewer appears with the modification + await expect(testPage.locator("diffs-container")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByText("Modified symlink target", { exact: false })).toBeVisible({ + timeout: 60_000, + }); + }); +}); diff --git a/apps/web/lib/types/workspace-files.ts b/apps/web/lib/types/workspace-files.ts index 6bb4e2d562..b3da69b2d5 100644 --- a/apps/web/lib/types/workspace-files.ts +++ b/apps/web/lib/types/workspace-files.ts @@ -3,6 +3,7 @@ export type FileTreeNode = { path: string; is_dir: boolean; size?: number; + is_symlink?: boolean; children?: FileTreeNode[]; };