fix: resolve symlinks in file tree so symlink-to-directory entries show as folders (#467)
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ export type FileTreeNode = {
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size?: number;
|
||||
is_symlink?: boolean;
|
||||
children?: FileTreeNode[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user