diff --git a/e2e-tests/problems.spec.ts b/e2e-tests/problems.spec.ts index 59793cf..06aa486 100644 --- a/e2e-tests/problems.spec.ts +++ b/e2e-tests/problems.spec.ts @@ -1,11 +1,11 @@ -import { test, testSkipIfWindows } from "./helpers/test_helper"; +import { test } from "./helpers/test_helper"; import { expect } from "@playwright/test"; import fs from "fs"; import path from "path"; const MINIMAL_APP = "minimal-with-ai-rules"; -testSkipIfWindows("problems auto-fix - enabled", async ({ po }) => { +test("problems auto-fix - enabled", async ({ po }) => { await po.setUp(); await po.importApp(MINIMAL_APP); await po.expectPreviewIframeIsVisible(); @@ -18,41 +18,35 @@ testSkipIfWindows("problems auto-fix - enabled", async ({ po }) => { await po.snapshotMessages({ replaceDumpPath: true }); }); -testSkipIfWindows( - "problems auto-fix - gives up after 2 attempts", - async ({ po }) => { - await po.setUp(); - await po.importApp(MINIMAL_APP); - await po.expectPreviewIframeIsVisible(); +test("problems auto-fix - gives up after 2 attempts", async ({ po }) => { + await po.setUp(); + await po.importApp(MINIMAL_APP); + await po.expectPreviewIframeIsVisible(); - await po.sendPrompt("tc=create-unfixable-ts-errors"); + await po.sendPrompt("tc=create-unfixable-ts-errors"); - await po.snapshotServerDump("all-messages", { dumpIndex: -2 }); - await po.snapshotServerDump("all-messages", { dumpIndex: -1 }); + await po.snapshotServerDump("all-messages", { dumpIndex: -2 }); + await po.snapshotServerDump("all-messages", { dumpIndex: -1 }); - await po.page.getByTestId("problem-summary").last().click(); - await expect( - po.page.getByTestId("problem-summary").last(), - ).toMatchAriaSnapshot(); - await po.snapshotMessages({ replaceDumpPath: true }); - }, -); + await po.page.getByTestId("problem-summary").last().click(); + await expect( + po.page.getByTestId("problem-summary").last(), + ).toMatchAriaSnapshot(); + await po.snapshotMessages({ replaceDumpPath: true }); +}); -testSkipIfWindows( - "problems auto-fix - complex delete-rename-write", - async ({ po }) => { - await po.setUp(); - await po.importApp(MINIMAL_APP); - await po.expectPreviewIframeIsVisible(); +test("problems auto-fix - complex delete-rename-write", async ({ po }) => { + await po.setUp(); + await po.importApp(MINIMAL_APP); + await po.expectPreviewIframeIsVisible(); - await po.sendPrompt("tc=create-ts-errors-complex"); + await po.sendPrompt("tc=create-ts-errors-complex"); - await po.snapshotServerDump("all-messages", { dumpIndex: -2 }); - await po.snapshotServerDump("all-messages", { dumpIndex: -1 }); + await po.snapshotServerDump("all-messages", { dumpIndex: -2 }); + await po.snapshotServerDump("all-messages", { dumpIndex: -1 }); - await po.snapshotMessages({ replaceDumpPath: true }); - }, -); + await po.snapshotMessages({ replaceDumpPath: true }); +}); test("problems auto-fix - disabled", async ({ po }) => { await po.setUp({ disableAutoFixProblems: true }); diff --git a/src/utils/VirtualFilesystem.ts b/src/utils/VirtualFilesystem.ts index 4fbc0eb..f876a0f 100644 --- a/src/utils/VirtualFilesystem.ts +++ b/src/utils/VirtualFilesystem.ts @@ -5,6 +5,7 @@ import { getDyadRenameTags, getDyadDeleteTags, } from "../ipc/processors/response_processor"; +import { normalizePath } from "../ipc/processors/normalizePath"; import log from "electron-log"; @@ -39,7 +40,39 @@ export abstract class BaseVirtualFileSystem { protected baseDir: string; constructor(baseDir: string) { - this.baseDir = baseDir; + this.baseDir = path.resolve(baseDir); + } + + /** + * Normalize path for consistent cross-platform behavior + */ + private normalizePathForKey(filePath: string): string { + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(this.baseDir, filePath); + + // Normalize separators and handle case-insensitive Windows paths + const normalized = normalizePath(path.normalize(absolutePath)); + + // Intentionally do NOT lowercase for Windows which is case-insensitive + // because this avoids issues with path comparison. + // + // This is a trade-off and introduces a small edge case where + // e.g. foo.txt and Foo.txt are treated as different files by the VFS + // even though Windows treats them as the same file. + // + // This should be a pretty rare occurence and it's not worth the extra + // complexity to handle it. + return normalized; + } + + /** + * Convert normalized path back to platform-appropriate format + */ + private denormalizePath(normalizedPath: string): string { + return process.platform === "win32" + ? normalizedPath.replace(/\//g, "\\") + : normalizedPath; } /** @@ -69,43 +102,49 @@ export abstract class BaseVirtualFileSystem { /** * Write a file to the virtual filesystem */ - public writeFile(relativePath: string, content: string): void { + protected writeFile(relativePath: string, content: string): void { const absolutePath = path.resolve(this.baseDir, relativePath); - this.virtualFiles.set(absolutePath, content); + const normalizedKey = this.normalizePathForKey(absolutePath); + + this.virtualFiles.set(normalizedKey, content); // Remove from deleted files if it was previously deleted - this.deletedFiles.delete(absolutePath); + this.deletedFiles.delete(normalizedKey); } /** * Delete a file from the virtual filesystem */ - public deleteFile(relativePath: string): void { + protected deleteFile(relativePath: string): void { const absolutePath = path.resolve(this.baseDir, relativePath); - this.deletedFiles.add(absolutePath); + const normalizedKey = this.normalizePathForKey(absolutePath); + + this.deletedFiles.add(normalizedKey); // Remove from virtual files if it exists there - this.virtualFiles.delete(absolutePath); + this.virtualFiles.delete(normalizedKey); } /** * Rename a file in the virtual filesystem */ - public renameFile(fromPath: string, toPath: string): void { + protected renameFile(fromPath: string, toPath: string): void { const fromAbsolute = path.resolve(this.baseDir, fromPath); const toAbsolute = path.resolve(this.baseDir, toPath); + const fromNormalized = this.normalizePathForKey(fromAbsolute); + const toNormalized = this.normalizePathForKey(toAbsolute); // Mark old file as deleted - this.deletedFiles.add(fromAbsolute); + this.deletedFiles.add(fromNormalized); // If the source file exists in virtual files, move its content - if (this.virtualFiles.has(fromAbsolute)) { - const content = this.virtualFiles.get(fromAbsolute)!; - this.virtualFiles.delete(fromAbsolute); - this.virtualFiles.set(toAbsolute, content); + if (this.virtualFiles.has(fromNormalized)) { + const content = this.virtualFiles.get(fromNormalized)!; + this.virtualFiles.delete(fromNormalized); + this.virtualFiles.set(toNormalized, content); } else { // Try to read from actual filesystem try { const content = fs.readFileSync(fromAbsolute, "utf8"); - this.virtualFiles.set(toAbsolute, content); + this.virtualFiles.set(toNormalized, content); } catch (error) { // If we can't read the source file, we'll let the consumer handle it logger.warn( @@ -116,7 +155,7 @@ export abstract class BaseVirtualFileSystem { } // Remove destination from deleted files if it was previously deleted - this.deletedFiles.delete(toAbsolute); + this.deletedFiles.delete(toNormalized); } /** @@ -124,10 +163,15 @@ export abstract class BaseVirtualFileSystem { */ public getVirtualFiles(): VirtualFile[] { return Array.from(this.virtualFiles.entries()).map( - ([absolutePath, content]) => ({ - path: path.relative(this.baseDir, absolutePath), - content, - }), + ([normalizedKey, content]) => { + // Convert normalized key back to relative path + const denormalizedPath = this.denormalizePath(normalizedKey); + + return { + path: path.relative(this.baseDir, denormalizedPath), + content, + }; + }, ); } @@ -135,84 +179,35 @@ export abstract class BaseVirtualFileSystem { * Get all deleted file paths (relative to base directory) */ public getDeletedFiles(): string[] { - return Array.from(this.deletedFiles).map((absolutePath) => - path.relative(this.baseDir, absolutePath), - ); - } - - /** - * Get all files that should be considered (existing + virtual - deleted) - */ - public getAllFiles(): string[] { - const allFiles = new Set(); - - // Add virtual files - for (const [absolutePath] of this.virtualFiles.entries()) { - allFiles.add(path.relative(this.baseDir, absolutePath)); - } - - // Add existing files (this is a simplified version - in practice you might want to scan the directory) - // This method is mainly for getting the current state, consumers can combine with directory scanning - - return Array.from(allFiles); - } - - /** - * Check if a file has been modified in the virtual filesystem - */ - public isFileModified(filePath: string): boolean { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(this.baseDir, filePath); - - return ( - this.virtualFiles.has(absolutePath) || this.deletedFiles.has(absolutePath) - ); - } - - /** - * Clear all virtual changes - */ - public clear(): void { - this.virtualFiles.clear(); - this.deletedFiles.clear(); - } - - /** - * Get the base directory - */ - public getBaseDir(): string { - return this.baseDir; + return Array.from(this.deletedFiles).map((normalizedKey) => { + // Convert normalized key back to relative path + const denormalizedPath = this.denormalizePath(normalizedKey); + return path.relative(this.baseDir, denormalizedPath); + }); } /** * Check if a file is deleted in the virtual filesystem */ protected isDeleted(filePath: string): boolean { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(this.baseDir, filePath); - return this.deletedFiles.has(absolutePath); + const normalizedKey = this.normalizePathForKey(filePath); + return this.deletedFiles.has(normalizedKey); } /** * Check if a file exists in virtual files */ protected hasVirtualFile(filePath: string): boolean { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(this.baseDir, filePath); - return this.virtualFiles.has(absolutePath); + const normalizedKey = this.normalizePathForKey(filePath); + return this.virtualFiles.has(normalizedKey); } /** * Get virtual file content */ protected getVirtualFileContent(filePath: string): string | undefined { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(this.baseDir, filePath); - return this.virtualFiles.get(absolutePath); + const normalizedKey = this.normalizePathForKey(filePath); + return this.virtualFiles.get(normalizedKey); } }