diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt index dd35c32e719..dfe7850243d 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt @@ -19,13 +19,11 @@ import org.eclipse.lsp4j.Range import org.eclipse.lsp4j.TextEdit import org.eclipse.lsp4j.WorkspaceEdit import software.aws.toolkits.core.utils.getLogger -import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CursorPosition import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CursorRange import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CursorState import java.io.File import java.net.URI -import java.net.URISyntaxException object LspEditorUtil { @@ -45,25 +43,7 @@ object LspEditorUtil { } private fun toUri(file: File): URI { - try { - // URI scheme specified by language server protocol - val uri = URI("file", "", file.absoluteFile.toURI().path, null) - val fallback = file.toPath().toAbsolutePath().normalize().toUri() - return if (uri.isCompliant()) uri else fallback - } catch (e: URISyntaxException) { - LOG.warn { "${e.localizedMessage}: $e" } - return file.absoluteFile.toURI() - } - } - - private fun URI.isCompliant(): Boolean { - if (!"file".equals(this.scheme, ignoreCase = true)) return true - - val path = this.rawPath ?: this.path.orEmpty() - val noAuthority = this.authority.isNullOrEmpty() - - // If the authority component is empty, the path cannot begin with two slash characters ("//") - return !(noAuthority && path.startsWith("//")) + return file.toPath().toAbsolutePath().normalize().toUri() } /** diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt index f768472a744..e5ac8d6fb30 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt @@ -170,4 +170,322 @@ class FileUriUtilTest { val result = LspEditorUtil.toUriString(virtualFile) assertThat(result).isNull() } + + @Test + fun `test URI generation does not create problematic Windows patterns`() { + // This test ensures that URIs generated by toUriString() don't contain + // patterns that would cause issues when embedded in filesystem paths on Windows + val virtualFile = createMockVirtualFile("/path/to/test/file.txt") + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:") + + // Critical: When this URI string is used as part of a filesystem path, + // it should not create mixed separators like "C:\Program Files\file:\C:\..." + // The fix ensures we use Path.toUri() which generates proper URIs + val uriString = result!! + + // Verify it follows proper file URI format + assertThat(uriString).matches("file:///.*") + + // Verify it doesn't contain Windows-style backslashes mixed with forward slashes + // (the original bug would create URIs that when stringified could cause this) + if (uriString.contains("\\")) { + // If backslashes are present, they should be properly encoded, not raw + assertThat(uriString).doesNotMatch(".*file:.*\\\\.*") + } + } + + // ========== EXPANDED WINDOWS URI CONSTRUCTION BUG REGRESSION TESTS ========== + + @Test + @EnabledOnOs(OS.WINDOWS) + fun `test Windows drive letters are handled correctly`() { + // Test various Windows drive letter scenarios + val testPaths = listOf( + "C:/Users/user/project/file.kt", + "D:/Development/aws-toolkit/test.java", + "E:/temp/amazonq/agents/config.json" + ) + + testPaths.forEach { path -> + val virtualFile = createMockVirtualFile(path) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + assertThat(result).doesNotContain("file:\\") + assertThat(result).doesNotContain("file:/C:") + + // Should properly encode drive letter + assertThat(result).matches("file:///[A-Z]:/.*") + } + } + + @Test + fun `test URI string cannot be embedded in filesystem paths`() { + // This is the core regression test for the Windows bug + val virtualFile = createMockVirtualFile("/Users/user/project/.amazonq/agents") + val uriString = LspEditorUtil.toUriString(virtualFile) + + assertThat(uriString).isNotNull() + + // Simulate how the URI would be used in a Windows filesystem path construction + val simulatedWindowsPath = "C:\\Program Files\\JetBrains\\PyCharm\\$uriString" + + // The bug would create paths like: C:\Program Files\JetBrains\PyCharm\file:\C:\Users\... + // Verify this doesn't happen + assertThat(simulatedWindowsPath).doesNotContain("file:\\C:") + assertThat(simulatedWindowsPath).doesNotContain("file:/C:") + + // Should be a valid URI format + assertThat(uriString).matches("file:///.*") + } + + @Test + fun `test URI consistency across multiple calls`() { + // Ensure the URI generation is deterministic + val virtualFile = createMockVirtualFile("/project/src/main/kotlin/Test.kt") + + val results = (1..10).map { LspEditorUtil.toUriString(virtualFile) } + + // All results should be identical + assertThat(results.toSet()).hasSize(1) + assertThat(results.first()).isNotNull() + assertThat(results.first()).startsWith("file:///") + } + + @Test + fun `test URI generation with special characters`() { + val specialPaths = listOf( + "/path/with spaces/file.txt", + "/path/with-dashes/file.txt", + "/path/with_underscores/file.txt", + "/path/with.dots/file.txt", + "/path/with(parentheses)/file.txt", + "/path/with[brackets]/file.txt", + "/path/with{braces}/file.txt", + "/path/with@symbols/file.txt", + "/path/with#hash/file.txt", + "/path/with%percent/file.txt" + ) + + specialPaths.forEach { path -> + val virtualFile = createMockVirtualFile(path) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + + // Special characters should be properly encoded + if (path.contains(" ")) { + assertThat(result).contains("%20") + } + if (path.contains("(")) { + assertThat(result).contains("%28") + } + if (path.contains(")")) { + assertThat(result).contains("%29") + } + } + } + + @Test + fun `test URI generation with unicode characters`() { + val unicodePaths = listOf( + "/path/with/café/file.txt", + "/path/with/文件/test.txt", + "/path/with/файл/document.txt", + "/path/with/ファイル/code.kt" + ) + + unicodePaths.forEach { path -> + val virtualFile = createMockVirtualFile(path) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + + // Unicode should be properly encoded + assertThat(result).doesNotContain("\\u") + } + } + + @Test + fun `test deeply nested paths`() { + val deepPath = "/very/deeply/nested/path/structure/that/goes/many/levels/deep/to/test/normalization/file.txt" + val virtualFile = createMockVirtualFile(deepPath) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + assertThat(result).endsWith("/file.txt") + + // Should not contain double slashes (except after scheme) + assertThat(result.substring(7)).doesNotContain("//") // Skip "file://" + } + + @Test + fun `test path normalization edge cases`() { + val pathsToNormalize = listOf( + "/path/../to/file.txt", // Parent directory reference + "/path/./to/file.txt", // Current directory reference + "/path//to///file.txt", // Multiple slashes + "/path/to/../from/file.txt", // Mixed references + "/../../../root/file.txt" // Multiple parent references + ) + + pathsToNormalize.forEach { path -> + val virtualFile = createMockVirtualFile(path) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + + // Normalized paths should not contain . or .. segments + assertThat(result).doesNotContain("/..") + assertThat(result).doesNotContain("/./") + + // Should not contain multiple consecutive slashes + assertThat(result.substring(7)).doesNotContain("//") + } + } + + @Test + fun `test case sensitivity preservation`() { + val caseSensitivePaths = listOf( + "/Path/To/File.TXT", + "/PATH/TO/FILE.txt", + "/path/to/file.TXT", + "/MyProject/SRC/Main/Kotlin/Test.kt" + ) + + caseSensitivePaths.forEach { path -> + val virtualFile = createMockVirtualFile(path) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + + // Case should be preserved in the URI + val resultPath = result!!.substringAfter("file:///") + assertThat(resultPath).isNotEqualTo(resultPath.lowercase()) + } + } + + @Test + fun `test concurrent URI generation`() { + // Test thread safety of URI generation + val virtualFile = createMockVirtualFile("/concurrent/test/file.kt") + val results = mutableListOf() + + // Simulate concurrent access + repeat(100) { + results.add(LspEditorUtil.toUriString(virtualFile)) + } + + // All results should be identical and valid + assertThat(results.toSet()).hasSize(1) + assertThat(results.first()).isNotNull() + assertThat(results.first()).startsWith("file:///") + } + + @Test + @EnabledOnOs(OS.WINDOWS) + fun `test Windows MCP workspace configuration paths`() { + // Specific test for the MCP configuration bug scenario + val mcpPaths = listOf( + "C:/Users/user/project/.amazonq/agents", + "C:/Users/user/workspace/.amazonq/config", + "D:/Development/project/.amazonq/mcp/settings.json", + "E:/Projects/aws-toolkit/.amazonq/agents/workspace.json" + ) + + mcpPaths.forEach { path -> + val virtualFile = createMockVirtualFile(path) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + + // Critical: When combined with IDE paths, should not create malformed paths + val ideBasePath = "C:\\Program Files\\JetBrains\\PyCharm" + val combinedPath = "$ideBasePath\\$result" + + // Should not contain the problematic pattern + assertThat(combinedPath).doesNotContain("\\file:\\C:") + assertThat(combinedPath).doesNotContain("\\file:/C:") + + // Verify proper URI format + assertThat(result).matches("file:///[A-Z]:/.*amazonq.*") + } + } + + @Test + fun `test integration with actual File objects`() { + // Integration test using real File objects + val tempFile = kotlin.io.path.createTempFile("uri-test", ".tmp").toFile() + try { + val virtualFile = createMockVirtualFile(tempFile.absolutePath) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + assertThat(result).endsWith(".tmp") + + // Should be a valid URI that can be parsed + val uri = java.net.URI.create(result!!) + assertThat(uri.scheme).isEqualTo("file") + assertThat(uri.path).isNotNull() + } finally { + tempFile.delete() + } + } + + @Test + fun `test error recovery with malformed paths`() { + // Test how the system handles potentially malformed input paths + val malformedPaths = listOf( + "", // Empty path + " ", // Whitespace only + "/", // Root only + "relative/path", // Relative path (no leading slash) + "\\windows\\style", // Windows-style separators on Unix + "C:", // Drive letter only + "file://already/uri" // Already a URI + ) + + malformedPaths.forEach { path -> + val virtualFile = createMockVirtualFile(path) + val result = LspEditorUtil.toUriString(virtualFile) + + if (result != null) { + // If a result is returned, it should be a valid URI + assertThat(result).startsWith("file:") + + // Should not crash when parsed as URI + assertThat { java.net.URI.create(result) }.doesNotThrowAnyException() + } + } + } + + @Test + fun `test performance with large number of paths`() { + // Performance regression test + val paths = (1..1000).map { "/performance/test/path/number/$it/file.kt" } + + val startTime = System.currentTimeMillis() + val results = paths.map { path -> + val virtualFile = createMockVirtualFile(path) + LspEditorUtil.toUriString(virtualFile) + } + val endTime = System.currentTimeMillis() + + // All results should be valid + assertThat(results).allMatch { it != null && it.startsWith("file:///") } + + // Should complete reasonably quickly (less than 5 seconds for 1000 URIs) + assertThat(endTime - startTime).isLessThan(5000) + } } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtilIntegrationTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtilIntegrationTest.kt new file mode 100644 index 00000000000..8ca5f14f262 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtilIntegrationTest.kt @@ -0,0 +1,297 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.extension.ExtendWith +import java.io.File +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createTempDirectory +import kotlin.io.path.createTempFile + +/** + * Integration tests for LspEditorUtil focusing on the Windows URI construction bug fix. + * These tests use real file system operations and cross-platform scenarios. + */ +@ExtendWith(ApplicationExtension::class) +class LspEditorUtilIntegrationTest { + + private fun createMockVirtualFile(path: String, mockProtocol: String = "file", mockIsDirectory: Boolean = false): VirtualFile = + mockk { + every { fileSystem } returns mockk { + every { protocol } returns mockProtocol + } + every { url } returns path + every { isDirectory } returns mockIsDirectory + } + + @Test + fun `integration test - URI generation with real temp files`() { + val tempFile = createTempFile("integration-test", ".kt") + try { + val virtualFile = createMockVirtualFile(tempFile.toAbsolutePath().toString()) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + + // Should be parseable as a valid URI + val uri = URI.create(result!!) + assertThat(uri.scheme).isEqualTo("file") + assertThat(uri.path).contains("integration-test") + assertThat(uri.path).endsWith(".kt") + + // Cross-check with Path.toUri() directly + val expectedUri = tempFile.toAbsolutePath().normalize().toUri() + assertThat(result).isEqualTo(expectedUri.toASCIIString()) + + } finally { + Files.deleteIfExists(tempFile) + } + } + + @Test + fun `integration test - URI generation with real directories`() { + val tempDir = createTempDirectory("integration-test-dir") + try { + val virtualFile = createMockVirtualFile(tempDir.toAbsolutePath().toString(), mockIsDirectory = true) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + assertThat(result).contains("integration-test-dir") + + // Directory URIs should not end with slash after processing + assertThat(result).doesNotEndWith("/") + assertThat(result).doesNotEndWith("\\") + + } finally { + Files.deleteIfExists(tempDir) + } + } + + @Test + @EnabledOnOs(OS.WINDOWS) + fun `integration test - Windows MCP workspace scenario`() { + // Simulate the exact scenario that caused the Windows bug + val tempDir = createTempDirectory("test-workspace") + val mcpDir = tempDir.resolve(".amazonq").resolve("agents") + Files.createDirectories(mcpDir) + + try { + val virtualFile = createMockVirtualFile(mcpDir.toAbsolutePath().toString(), mockIsDirectory = true) + val uriString = LspEditorUtil.toUriString(virtualFile) + + assertThat(uriString).isNotNull() + assertThat(uriString).startsWith("file:///") + + // Simulate the problematic path construction that was failing + val ideBasePath = "C:\\Program Files\\JetBrains\\PyCharm Community Edition 2024.1" + val problematicPath = "$ideBasePath\\$uriString" + + // The bug would create: C:\Program Files\JetBrains\PyCharm Community Edition 2024.1\file:\C:\... + // Verify this doesn't happen + assertThat(problematicPath).doesNotContain("\\file:\\") + assertThat(problematicPath).doesNotContain("\\file:/") + + // URI should be well-formed + assertThat(uriString).matches("file:///[A-Za-z]:/.*\\.amazonq.*agents.*") + + } finally { + Files.walk(tempDir).sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } + + @Test + fun `integration test - cross-platform path handling`() { + val tempFile = createTempFile("cross-platform-test", ".json") + try { + val absolutePath = tempFile.toAbsolutePath().toString() + val virtualFile = createMockVirtualFile(absolutePath) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + + // Test URI can be converted back to Path consistently + val uri = URI.create(result!!) + val reconstructedPath = Path.of(uri) + assertThat(reconstructedPath.toAbsolutePath().normalize()).isEqualTo(tempFile.toAbsolutePath().normalize()) + + } finally { + Files.deleteIfExists(tempFile) + } + } + + @Test + fun `integration test - workspace with special characters`() { + // Create a workspace with special characters that could cause URI issues + val tempDir = createTempDirectory("test workspace with spaces & symbols") + val projectFile = tempDir.resolve("My Project (2024).kt") + Files.createFile(projectFile) + + try { + val virtualFile = createMockVirtualFile(projectFile.toAbsolutePath().toString()) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + + // Special characters should be properly encoded + assertThat(result).contains("%20") // Space + assertThat(result).contains("%28") // ( + assertThat(result).contains("%29") // ) + + // URI should be parseable + val uri = URI.create(result!!) + assertThat(uri.path).contains("My Project (2024).kt") + + } finally { + Files.walk(tempDir).sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } + + @Test + fun `integration test - nested project structure`() { + // Create a realistic project structure + val tempDir = createTempDirectory("aws-toolkit-test") + val srcDir = tempDir.resolve("src").resolve("main").resolve("kotlin") + val packageDir = srcDir.resolve("software").resolve("aws").resolve("toolkits") + Files.createDirectories(packageDir) + + val testFile = packageDir.resolve("TestClass.kt") + Files.createFile(testFile) + + try { + val virtualFile = createMockVirtualFile(testFile.toAbsolutePath().toString()) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + assertThat(result).contains("aws-toolkit-test") + assertThat(result).contains("TestClass.kt") + assertThat(result).endsWith(".kt") + + // Deep path should be properly normalized + assertThat(result.substring(7)).doesNotContain("//") // No double slashes after scheme + + } finally { + Files.walk(tempDir).sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } + + @Test + fun `integration test - concurrent URI generation with real files`() { + val tempFiles = (1..10).map { createTempFile("concurrent-test-$it", ".kt") } + + try { + val results = tempFiles.parallelStream().map { tempFile -> + val virtualFile = createMockVirtualFile(tempFile.toAbsolutePath().toString()) + LspEditorUtil.toUriString(virtualFile) + }.toList() + + // All results should be valid and unique + assertThat(results).allMatch { it != null && it.startsWith("file:///") } + assertThat(results.toSet()).hasSize(tempFiles.size) // All unique + + // Each result should correspond to its file + results.forEachIndexed { index, result -> + assertThat(result).contains("concurrent-test-${index + 1}") + assertThat(result).endsWith(".kt") + } + + } finally { + tempFiles.forEach { Files.deleteIfExists(it) } + } + } + + @Test + @EnabledOnOs(OS.WINDOWS) + fun `integration test - Windows drive letter variations`() { + // Test different Windows drive scenarios + val drivePaths = listOf("C:", "D:", "E:", "F:") + + drivePaths.forEach { drive -> + val simulatedPath = "$drive\\Users\\TestUser\\project\\file.kt" + val normalizedPath = simulatedPath.replace("\\", "/") + + val virtualFile = createMockVirtualFile(normalizedPath) + val result = LspEditorUtil.toUriString(virtualFile) + + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + assertThat(result).matches("file:///${drive[0]}:/.*") + + // Should not contain problematic patterns + assertThat(result).doesNotContain("file:\\") + assertThat(result).doesNotContain("\\\\") + } + } + + @Test + fun `integration test - comparison with Java File.toURI()`() { + val tempFile = createTempFile("comparison-test", ".java") + + try { + val javaFile = tempFile.toFile() + val javaUri = javaFile.toURI() + val pathUri = tempFile.toUri() + + val virtualFile = createMockVirtualFile(tempFile.toAbsolutePath().toString()) + val lspUri = LspEditorUtil.toUriString(virtualFile) + + assertThat(lspUri).isNotNull() + + // Our implementation should match Path.toUri() (not File.toURI()) + assertThat(lspUri).isEqualTo(pathUri.toASCIIString()) + + // All should represent the same file + val lspUriObject = URI.create(lspUri!!) + assertThat(Path.of(lspUriObject)).isEqualTo(Path.of(javaUri)) + assertThat(Path.of(lspUriObject)).isEqualTo(Path.of(pathUri)) + + } finally { + Files.deleteIfExists(tempFile) + } + } + + @Test + fun `integration test - verify fix prevents ENOENT errors`() { + // This test simulates the exact error condition that was occurring + val tempDir = createTempDirectory("enoent-test") + val mcpAgentsDir = tempDir.resolve(".amazonq").resolve("agents") + Files.createDirectories(mcpAgentsDir) + + try { + val virtualFile = createMockVirtualFile(mcpAgentsDir.toAbsolutePath().toString(), mockIsDirectory = true) + val uriString = LspEditorUtil.toUriString(virtualFile) + + assertThat(uriString).isNotNull() + + // Simulate how the URI was being used in path construction + val baseDirectory = File(System.getProperty("java.io.tmpdir")) + val attemptedPath = File(baseDirectory, uriString!!) + + // The old bug would create paths like: /tmp/file:/C:/Users/... + // which would fail with ENOENT. Verify the path is now reasonable. + assertThat(attemptedPath.path).doesNotContain("file:") + + // The URI should be a proper URI, not something that gets mixed into filesystem paths + assertThat(uriString).startsWith("file:///") + val uri = URI.create(uriString) + assertThat(uri.scheme).isEqualTo("file") + + } finally { + Files.walk(tempDir).sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } +} \ No newline at end of file diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtilPerformanceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtilPerformanceTest.kt new file mode 100644 index 00000000000..f7342ce6535 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtilPerformanceTest.kt @@ -0,0 +1,249 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.system.measureTimeMillis + +/** + * Performance regression tests for LspEditorUtil to ensure the URI construction fix + * doesn't introduce performance degradation. + */ +@ExtendWith(ApplicationExtension::class) +class LspEditorUtilPerformanceTest { + + private fun createMockVirtualFile(path: String, mockProtocol: String = "file", mockIsDirectory: Boolean = false): VirtualFile = + mockk { + every { fileSystem } returns mockk { + every { protocol } returns mockProtocol + } + every { url } returns path + every { isDirectory } returns mockIsDirectory + } + + @Test + fun `performance test - single URI generation should be fast`() { + val virtualFile = createMockVirtualFile("/performance/test/single/file.kt") + + val timeMs = measureTimeMillis { + val result = LspEditorUtil.toUriString(virtualFile) + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + } + + // Single URI generation should take less than 10ms + assertThat(timeMs).isLessThan(10) + } + + @Test + fun `performance test - batch URI generation`() { + val paths = (1..1000).map { "/performance/test/batch/file$it.kt" } + val virtualFiles = paths.map { createMockVirtualFile(it) } + + val timeMs = measureTimeMillis { + val results = virtualFiles.map { LspEditorUtil.toUriString(it) } + + // Verify all results are valid + assertThat(results).allMatch { it != null && it.startsWith("file:///") } + assertThat(results).hasSize(1000) + } + + // 1000 URI generations should take less than 1 second + assertThat(timeMs).isLessThan(1000) + + // Average should be less than 1ms per URI + val avgTimePerUri = timeMs.toDouble() / 1000 + assertThat(avgTimePerUri).isLessThan(1.0) + } + + @Test + fun `performance test - concurrent URI generation`() { + val paths = (1..100).map { "/performance/test/concurrent/file$it.kt" } + val virtualFiles = paths.map { createMockVirtualFile(it) } + + val timeMs = measureTimeMillis { + val results = virtualFiles.parallelStream().map { virtualFile -> + LspEditorUtil.toUriString(virtualFile) + }.toList() + + // Verify all results are valid + assertThat(results).allMatch { it != null && it.startsWith("file:///") } + assertThat(results).hasSize(100) + assertThat(results.toSet()).hasSize(100) // All unique + } + + // Concurrent processing should be faster than sequential + // and complete within 500ms for 100 URIs + assertThat(timeMs).isLessThan(500) + } + + @Test + fun `performance test - complex path normalization`() { + val complexPaths = (1..100).map { index -> + "/very/complex/../path/with/./many/../segments/that/require/./normalization/../file$index.kt" + } + val virtualFiles = complexPaths.map { createMockVirtualFile(it) } + + val timeMs = measureTimeMillis { + val results = virtualFiles.map { LspEditorUtil.toUriString(it) } + + // Verify all results are valid and normalized + assertThat(results).allMatch { uri -> + uri != null && + uri.startsWith("file:///") && + !uri.contains("/./") && + !uri.contains("/../") + } + } + + // Complex path normalization for 100 paths should take less than 200ms + assertThat(timeMs).isLessThan(200) + } + + @Test + fun `performance test - paths with special characters`() { + val specialCharPaths = (1..200).map { index -> + "/path/with spaces/and-special#chars/file$index (copy).kt" + } + val virtualFiles = specialCharPaths.map { createMockVirtualFile(it) } + + val timeMs = measureTimeMillis { + val results = virtualFiles.map { LspEditorUtil.toUriString(it) } + + // Verify all results are valid with proper encoding + assertThat(results).allMatch { uri -> + uri != null && + uri.startsWith("file:///") && + uri.contains("%20") && // Space encoding + uri.contains("%28") && // ( encoding + uri.contains("%29") // ) encoding + } + } + + // Special character encoding for 200 paths should take less than 300ms + assertThat(timeMs).isLessThan(300) + } + + @Test + fun `performance test - very long paths`() { + val longPaths = (1..50).map { index -> + val longSegment = "very-long-directory-name-that-simulates-deep-nesting".repeat(5) + "/$longSegment/path$index/file$index.kt" + } + val virtualFiles = longPaths.map { createMockVirtualFile(it) } + + val timeMs = measureTimeMillis { + val results = virtualFiles.map { LspEditorUtil.toUriString(it) } + + // Verify all results are valid + assertThat(results).allMatch { it != null && it.startsWith("file:///") } + assertThat(results).hasSize(50) + } + + // Long path processing for 50 paths should take less than 100ms + assertThat(timeMs).isLessThan(100) + } + + @Test + fun `performance test - repeated calls with same path`() { + val virtualFile = createMockVirtualFile("/performance/test/repeated/file.kt") + + val timeMs = measureTimeMillis { + repeat(10000) { + val result = LspEditorUtil.toUriString(virtualFile) + assertThat(result).isNotNull() + assertThat(result).startsWith("file:///") + } + } + + // 10,000 repeated calls should take less than 2 seconds + assertThat(timeMs).isLessThan(2000) + + // Average should be less than 0.2ms per call + val avgTimePerCall = timeMs.toDouble() / 10000 + assertThat(avgTimePerCall).isLessThan(0.2) + } + + @Test + fun `performance test - directory vs file processing`() { + val files = (1..500).map { createMockVirtualFile("/perf/files/file$it.kt", mockIsDirectory = false) } + val directories = (1..500).map { createMockVirtualFile("/perf/dirs/dir$it", mockIsDirectory = true) } + + val fileTimeMs = measureTimeMillis { + val results = files.map { LspEditorUtil.toUriString(it) } + assertThat(results).allMatch { it != null && it.startsWith("file:///") && it.endsWith(".kt") } + } + + val dirTimeMs = measureTimeMillis { + val results = directories.map { LspEditorUtil.toUriString(it) } + assertThat(results).allMatch { it != null && it.startsWith("file:///") && !it.endsWith("/") } + } + + // Both should be fast and similar performance + assertThat(fileTimeMs).isLessThan(500) + assertThat(dirTimeMs).isLessThan(500) + + // Directory processing might be slightly faster due to no extension, + // but should not be more than 2x different + val ratio = maxOf(fileTimeMs.toDouble() / dirTimeMs, dirTimeMs.toDouble() / fileTimeMs) + assertThat(ratio).isLessThan(2.0) + } + + @Test + fun `performance test - memory usage stability`() { + // Test that repeated URI generation doesn't cause memory leaks + val virtualFile = createMockVirtualFile("/performance/test/memory/file.kt") + + val initialMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() + + // Generate many URIs + repeat(10000) { + val result = LspEditorUtil.toUriString(virtualFile) + assertThat(result).isNotNull() + } + + // Force garbage collection + System.gc() + Thread.sleep(100) // Give GC time to run + + val finalMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() + val memoryIncrease = finalMemory - initialMemory + + // Memory increase should be minimal (less than 10MB) + assertThat(memoryIncrease).isLessThan(10 * 1024 * 1024) + } + + @Test + fun `performance benchmark - before vs after fix comparison`() { + // This test documents the performance characteristics of the new implementation + val testPaths = (1..1000).map { "/benchmark/path$it/file.kt" } + val virtualFiles = testPaths.map { createMockVirtualFile(it) } + + val results = mutableListOf() + val timeMs = measureTimeMillis { + virtualFiles.forEach { virtualFile -> + results.add(LspEditorUtil.toUriString(virtualFile)) + } + } + + // Document performance characteristics + println("URI Generation Performance Benchmark:") + println("- Total URIs generated: ${results.size}") + println("- Total time: ${timeMs}ms") + println("- Average time per URI: ${timeMs.toDouble() / results.size}ms") + println("- URIs per second: ${(results.size * 1000.0 / timeMs).toInt()}") + + // Verify all results are valid + assertThat(results).allMatch { it != null && it.startsWith("file:///") } + + // Performance should be reasonable + assertThat(timeMs).isLessThan(1000) // Less than 1 second for 1000 URIs + assertThat(results.size * 1000.0 / timeMs).isGreaterThan(1000.0) // More than 1000 URIs/second + } +} \ No newline at end of file