fix: resolve symlinks in file tree so symlink-to-directory entries show as folders (#467)

This commit is contained in:
Carlos Florêncio
2026-03-25 23:00:05 +00:00
committed by GitHub
parent ad85d2c98a
commit 116895b4ff
6 changed files with 337 additions and 1 deletions
+43
View File
@@ -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(),
@@ -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)
}
@@ -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")
}
})
}
@@ -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"`
}
+181
View File
@@ -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<SessionPage> {
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,
});
});
});
+1
View File
@@ -3,6 +3,7 @@ export type FileTreeNode = {
path: string;
is_dir: boolean;
size?: number;
is_symlink?: boolean;
children?: FileTreeNode[];
};