diff --git a/src/node/config.test.ts b/src/node/config.test.ts index d4b0a1d891..eacd60ae21 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -18,6 +18,32 @@ describe("Config", () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + describe("loadConfigOrDefault with trailing slash migration", () => { + it("should strip trailing slashes from project paths on load", () => { + // Create config file with trailing slashes in project paths + const configFile = path.join(tempDir, "config.json"); + const corruptedConfig = { + projects: [ + ["/home/user/project/", { workspaces: [] }], + ["/home/user/another//", { workspaces: [] }], + ["/home/user/clean", { workspaces: [] }], + ], + }; + fs.writeFileSync(configFile, JSON.stringify(corruptedConfig)); + + // Load config - should migrate paths + const loaded = config.loadConfigOrDefault(); + + // Verify paths are normalized (no trailing slashes) + const projectPaths = Array.from(loaded.projects.keys()); + expect(projectPaths).toContain("/home/user/project"); + expect(projectPaths).toContain("/home/user/another"); + expect(projectPaths).toContain("/home/user/clean"); + expect(projectPaths).not.toContain("/home/user/project/"); + expect(projectPaths).not.toContain("/home/user/another//"); + }); + }); + describe("generateStableId", () => { it("should generate a 10-character hex string", () => { const id = config.generateStableId(); diff --git a/src/node/config.ts b/src/node/config.ts index 930a6352b4..5110cac4a3 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -11,6 +11,7 @@ import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; import { PlatformPaths } from "@/common/utils/paths"; +import { stripTrailingSlashes } from "@/node/utils/pathUtils"; // Re-export project types from dedicated types file (for preload usage) export type { Workspace, ProjectConfig, ProjectsConfig }; @@ -56,9 +57,13 @@ export class Config { // Config is stored as array of [path, config] pairs if (parsed.projects && Array.isArray(parsed.projects)) { - const projectsMap = new Map( - parsed.projects as Array<[string, ProjectConfig]> - ); + const rawPairs = parsed.projects as Array<[string, ProjectConfig]>; + // Migrate: normalize project paths by stripping trailing slashes + // This fixes configs created with paths like "/home/user/project/" + const normalizedPairs = rawPairs.map(([projectPath, projectConfig]) => { + return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig]; + }); + const projectsMap = new Map(normalizedPairs); return { projects: projectsMap, }; diff --git a/src/node/utils/pathUtils.test.ts b/src/node/utils/pathUtils.test.ts index 3d925a98f9..9caa896d88 100644 --- a/src/node/utils/pathUtils.test.ts +++ b/src/node/utils/pathUtils.test.ts @@ -129,5 +129,23 @@ describe("pathUtils", () => { expect(result.valid).toBe(true); expect(result.expandedPath).toBe(tempDir); }); + + it("should strip trailing slashes from path", async () => { + // Create .git directory for validation + // eslint-disable-next-line local/no-sync-fs-methods -- Test setup only + fs.mkdirSync(path.join(tempDir, ".git")); + + // Test with single trailing slash + const resultSingle = await validateProjectPath(`${tempDir}/`); + expect(resultSingle.valid).toBe(true); + expect(resultSingle.expandedPath).toBe(tempDir); + expect(resultSingle.expandedPath).not.toMatch(/[/\\]$/); + + // Test with multiple trailing slashes + const resultMultiple = await validateProjectPath(`${tempDir}//`); + expect(resultMultiple.valid).toBe(true); + expect(resultMultiple.expandedPath).toBe(tempDir); + expect(resultMultiple.expandedPath).not.toMatch(/[/\\]$/); + }); }); }); diff --git a/src/node/utils/pathUtils.ts b/src/node/utils/pathUtils.ts index 85912a8232..3e3121f488 100644 --- a/src/node/utils/pathUtils.ts +++ b/src/node/utils/pathUtils.ts @@ -26,6 +26,21 @@ export function expandTilde(inputPath: string): string { return PlatformPaths.expandHome(inputPath); } +/** + * Strip trailing slashes from a path. + * path.normalize() preserves a single trailing slash which breaks basename extraction. + * + * @param inputPath - Path that may have trailing slashes + * @returns Path without trailing slashes + * + * @example + * stripTrailingSlashes("/home/user/project/") // => "/home/user/project" + * stripTrailingSlashes("/home/user/project//") // => "/home/user/project" + */ +export function stripTrailingSlashes(inputPath: string): string { + return inputPath.replace(/[/\\]+$/, ""); +} + /** * Validate that a project path exists, is a directory, and is a git repository * Automatically expands tilde and normalizes the path @@ -47,8 +62,8 @@ export async function validateProjectPath(inputPath: string): Promise browserName !== "chromium", + "Electron scenario runs on chromium only" +); + +test.describe("Project Path Handling", () => { + test("project with trailing slash displays correctly", async ({ workspace, page }) => { + const { configRoot } = workspace; + const srcDir = path.join(configRoot, "src"); + const sessionsDir = path.join(configRoot, "sessions"); + + // Create a project path WITH trailing slash to simulate the bug + const projectPathWithSlash = path.join(configRoot, "fixtures", "trailing-slash-project") + "/"; + const projectName = "trailing-slash-project"; // Expected extracted name + const workspaceBranch = "test-branch"; + const workspacePath = path.join(srcDir, projectName, workspaceBranch); + + // Create directories + fs.mkdirSync(path.dirname(projectPathWithSlash), { recursive: true }); + fs.mkdirSync(projectPathWithSlash, { recursive: true }); + fs.mkdirSync(workspacePath, { recursive: true }); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // Initialize git repos + for (const repoPath of [projectPathWithSlash, workspacePath]) { + spawnSync("git", ["init", "-q"], { cwd: repoPath }); + spawnSync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath }); + spawnSync("git", ["config", "user.name", "Test"], { cwd: repoPath }); + spawnSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: repoPath }); + } + + // Write config with trailing slash in project path - this tests the migration + const configPayload = { + projects: [[projectPathWithSlash, { workspaces: [{ path: workspacePath }] }]], + }; + fs.writeFileSync(path.join(configRoot, "config.json"), JSON.stringify(configPayload, null, 2)); + + // Create workspace session with metadata + const workspaceId = `${projectName}-${workspaceBranch}`; + const workspaceSessionDir = path.join(sessionsDir, workspaceId); + fs.mkdirSync(workspaceSessionDir, { recursive: true }); + fs.writeFileSync( + path.join(workspaceSessionDir, "metadata.json"), + JSON.stringify({ + id: workspaceId, + name: workspaceBranch, + projectName, + projectPath: projectPathWithSlash, + }) + ); + fs.writeFileSync(path.join(workspaceSessionDir, "chat.jsonl"), ""); + + // Reload the page to pick up the new config + await page.reload(); + await page.waitForLoadState("domcontentloaded"); + + // Find the project in the sidebar - it should show the project name, not empty + const navigation = page.getByRole("navigation", { name: "Projects" }); + await expect(navigation).toBeVisible(); + + // The project name should be visible (extracted correctly despite trailing slash) + // If the bug was present, we'd see an empty project name or just "/" + await expect(navigation.getByText(projectName)).toBeVisible(); + + // Verify the workspace is also visible under the project + const projectItem = navigation.locator('[role="button"][aria-controls]').first(); + await expect(projectItem).toBeVisible(); + + // Expand to see workspace + const expandButton = projectItem.getByRole("button", { name: /expand project/i }); + if (await expandButton.isVisible()) { + await expandButton.click(); + } + + // Workspace branch should be visible + await expect(navigation.getByText(workspaceBranch)).toBeVisible(); + }); +});