diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts index b2c0d317cc5..985e107b688 100644 --- a/buildSrc/settings.gradle.kts +++ b/buildSrc/settings.gradle.kts @@ -43,5 +43,11 @@ dependencyResolutionManagement { includeGroupByRegex("org\\.jetbrains\\.intellij\\.platform.*") } } + maven { + url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") + content { + includeGroupByRegex("org\\.mockito\\.kotlin") + } + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f80f1e72ea1..bc7d7457ab3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ detekt = "1.23.7" diff-util = "4.12" intellijExt = "1.1.8" # match with /settings.gradle.kts -intellijGradle = "2.3.0" +intellijGradle = "2.6.0" intellijRemoteRobot = "0.11.22" jackson = "2.17.2" jacoco = "0.8.12" @@ -24,7 +24,7 @@ kotlin = "2.1.20" kotlinCoroutines = "1.8.0" lsp4j = "0.24.0" mockito = "5.12.0" -mockitoKotlin = "5.4.0" +mockitoKotlin = "5.4.1-SNAPSHOT" mockk = "1.13.17" nimbus-jose-jwt = "9.40" node-gradle = "7.0.2" diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QRefreshPanelAction.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QRefreshPanelAction.kt index 14b551f25b6..52ed6897ec3 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QRefreshPanelAction.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QRefreshPanelAction.kt @@ -7,8 +7,10 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.currentThreadCoroutineScope import com.intellij.openapi.project.DumbAwareAction import com.intellij.util.messages.Topic +import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.ChatCommunicationManager import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CHAT_TAB_REMOVE @@ -22,9 +24,11 @@ class QRefreshPanelAction : DumbAwareAction(AmazonQBundle.message("amazonq.refre // Notify LSP server about all open tabs being removed val chatManager = ChatCommunicationManager.getInstance(project) - chatManager.getAllTabIds().forEach { tabId -> - AmazonQLspService.executeIfRunning(project) { server -> - rawEndpoint.notify(CHAT_TAB_REMOVE, mapOf("tabId" to tabId)) + currentThreadCoroutineScope().launch { + chatManager.getAllTabIds().forEach { tabId -> + AmazonQLspService.executeAsyncIfRunning(project) { + rawEndpoint.notify(CHAT_TAB_REMOVE, mapOf("tabId" to tabId)) + } } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt index 89b02987bae..915a3a23343 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt @@ -116,7 +116,7 @@ class BrowserConnector( private val themeBrowserAdapter: ThemeBrowserAdapter = ThemeBrowserAdapter(), private val project: Project, ) { - var uiReady = CompletableDeferred() + val uiReady = CompletableDeferred() private val chatCommunicationManager = ChatCommunicationManager.getInstance(project) private val chatAsyncResultManager = ChatAsyncResultManager.getInstance(project) @@ -216,7 +216,7 @@ class BrowserConnector( } } - private fun handleFlareChatMessages(browser: Browser, node: JsonNode) { + private suspend fun handleFlareChatMessages(browser: Browser, node: JsonNode) { when (node.command) { SEND_CHAT_COMMAND_PROMPT -> { val requestFromUi = serializer.deserializeChatMessages(node) @@ -238,7 +238,7 @@ class BrowserConnector( chatCommunicationManager.registerPartialResultToken(partialResultToken) var encryptionManager: JwtEncryptionManager? = null - val result = AmazonQLspService.executeIfRunning(project) { server -> + val result = AmazonQLspService.executeAsyncIfRunning(project) { server -> encryptionManager = this.encryptionManager val encryptedParams = EncryptedChatParams(this.encryptionManager.encrypt(chatParams), partialResultToken) @@ -258,7 +258,7 @@ class BrowserConnector( val partialResultToken = chatCommunicationManager.addPartialChatMessage(tabId) chatCommunicationManager.registerPartialResultToken(partialResultToken) var encryptionManager: JwtEncryptionManager? = null - val result = AmazonQLspService.executeIfRunning(project) { server -> + val result = AmazonQLspService.executeAsyncIfRunning(project) { server -> encryptionManager = this.encryptionManager val encryptedParams = EncryptedQuickActionChatParams(this.encryptionManager.encrypt(quickActionParams), partialResultToken) @@ -613,7 +613,7 @@ class BrowserConnector( } } - private inline fun handleChat( + private suspend inline fun handleChat( lspMethod: JsonRpcMethod, node: JsonNode, crossinline serverAction: (params: Request, invokeService: () -> CompletableFuture) -> CompletableFuture, @@ -624,7 +624,7 @@ class BrowserConnector( serializer.deserializeChatMessages(node.params, lspMethod.params) } - return AmazonQLspService.executeIfRunning(project) { _ -> + return AmazonQLspService.executeAsyncIfRunning(project) { _ -> val invokeService = when (lspMethod) { is JsonRpcNotification -> { // notify is Unit @@ -646,7 +646,7 @@ class BrowserConnector( } ?: CompletableFuture.failedFuture(IllegalStateException("LSP Server not running")) } - private inline fun handleChat( + private suspend inline fun handleChat( lspMethod: JsonRpcMethod, node: JsonNode, ): CompletableFuture = handleChat( diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt index 8d66174f125..dac4edc2f7a 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt @@ -170,7 +170,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { whenever(featureDevClient.createTaskAssistConversation()).thenReturn(exampleCreateTaskAssistConversationResponse) whenever(featureDevClient.sendFeatureDevTelemetryEvent(any())).thenReturn(exampleSendTelemetryEventResponse) whenever(chatSessionStorage.getSession(any(), any())).thenReturn(spySession) - doNothing().`when`(chatSessionStorage).deleteSession(any()) + doNothing().whenever(chatSessionStorage).deleteSession(any()) mockkObject(AmazonqTelemetry) every { AmazonqTelemetry.endChat(amazonqConversationId = any(), amazonqEndOfTheConversationLatency = any()) } just runs diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt index 6dde06c20a3..9a62fcff440 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt @@ -187,11 +187,17 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { CodeWhispererInvocationStatus.getInstance().setInvocationStart() var nextToken: Either? = null do { - val result = AmazonQLspService.executeIfRunning(requestContext.project) { server -> + val result = AmazonQLspService.executeAsyncIfRunning(requestContext.project) { server -> val params = createInlineCompletionParams(requestContext.editor, requestContext.triggerTypeInfo, nextToken) server.inlineCompletionWithReferences(params) } - val completion = result?.await() ?: break + val completion = result?.await() + if (completion == null) { + // no result / not running + CodeWhispererInvocationStatus.getInstance().finishInvocation() + break + } + nextToken = completion.partialResultToken val endTime = System.nanoTime() val latency = TimeUnit.NANOSECONDS.toMillis(endTime - startTime).toDouble() @@ -426,7 +432,7 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { CodeWhispererTelemetryService.getInstance().sendUserTriggerDecisionEvent(project, latencyContext, sessionId, recommendationContext) } - fun getRequestContext( + suspend fun getRequestContext( triggerTypeInfo: TriggerTypeInfo, editor: Editor, project: Project, @@ -472,11 +478,11 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { ) } - fun getWorkspaceIds(project: Project): CompletableFuture { + suspend fun getWorkspaceIds(project: Project): CompletableFuture { val payload = GetConfigurationFromServerParams( section = "aws.q.workspaceContext" ) - return AmazonQLspService.executeIfRunning(project) { server -> + return AmazonQLspService.executeAsyncIfRunning(project) { server -> server.getConfigurationFromServer(payload) } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt index 234843e417d..42c557b8b2a 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererServiceNew.kt @@ -203,7 +203,7 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { var requestCount = 0 var nextToken: Either? = null do { - val result = AmazonQLspService.executeIfRunning(requestContext.project) { server -> + val result = AmazonQLspService.executeAsyncIfRunning(requestContext.project) { server -> val params = createInlineCompletionParams(requestContext.editor, requestContext.triggerTypeInfo, nextToken) server.inlineCompletionWithReferences(params) } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt index 4a8d97575a5..dd10d1747b2 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt @@ -10,6 +10,7 @@ import com.intellij.openapi.options.BoundConfigurable import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.options.ex.Settings +import com.intellij.openapi.progress.currentThreadCoroutineScope import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.ui.emptyText @@ -22,6 +23,8 @@ import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel import com.intellij.util.concurrency.EdtExecutorService import com.intellij.util.execution.ParametersListUtil +import kotlinx.coroutines.launch +import org.eclipse.lsp4j.DidChangeConfigurationParams import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService @@ -310,7 +313,12 @@ class CodeWhispererConfigurable(private val project: Project) : if (project.isDisposed) { return@forEach } - AmazonQLspService.didChangeConfiguration(project) + + currentThreadCoroutineScope().launch { + AmazonQLspService.executeAsyncIfRunning(project) { server -> + server.workspaceService.didChangeConfiguration(DidChangeConfigurationParams()) + } + } } } } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt index ff81a6888c8..f2bccb3beef 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt @@ -6,6 +6,8 @@ package software.aws.toolkits.jetbrains.services.codewhisperer.telemetry import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService @@ -33,7 +35,7 @@ import java.time.Duration import java.time.Instant @Service -class CodeWhispererTelemetryService { +class CodeWhispererTelemetryService(private val cs: CoroutineScope) { companion object { fun getInstance(): CodeWhispererTelemetryService = service() val LOG = getLogger() @@ -45,22 +47,24 @@ class CodeWhispererTelemetryService { sessionId: String, recommendationContext: RecommendationContext, ) { - AmazonQLspService.executeIfRunning(project) { server -> - val params = LogInlineCompletionSessionResultsParams( - sessionId = sessionId, - completionSessionResult = recommendationContext.details.associate { - it.itemId to InlineCompletionStates( - seen = it.hasSeen, - accepted = it.isAccepted, - discarded = it.isDiscarded - ) - }, - firstCompletionDisplayLatency = latencyContext.perceivedLatency, - totalSessionDisplayTime = CodeWhispererInvocationStatus.getInstance().completionShownTime?.let { Duration.between(it, Instant.now()) } - ?.toMillis()?.toDouble(), - typeaheadLength = recommendationContext.userInput.length.toLong() - ) - server.logInlineCompletionSessionResults(params) + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { server -> + val params = LogInlineCompletionSessionResultsParams( + sessionId = sessionId, + completionSessionResult = recommendationContext.details.associate { + it.itemId to InlineCompletionStates( + seen = it.hasSeen, + accepted = it.isAccepted, + discarded = it.isDiscarded + ) + }, + firstCompletionDisplayLatency = latencyContext.perceivedLatency, + totalSessionDisplayTime = CodeWhispererInvocationStatus.getInstance().completionShownTime?.let { Duration.between(it, Instant.now()) } + ?.toMillis()?.toDouble(), + typeaheadLength = recommendationContext.userInput.length.toLong() + ) + server.logInlineCompletionSessionResults(params) + } } } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryServiceNew.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryServiceNew.kt index 73106cd4714..2252c26b855 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryServiceNew.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryServiceNew.kt @@ -6,6 +6,8 @@ package software.aws.toolkits.jetbrains.services.codewhisperer.telemetry import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService @@ -29,7 +31,7 @@ import java.time.Duration import java.time.Instant @Service -class CodeWhispererTelemetryServiceNew { +class CodeWhispererTelemetryServiceNew(private val cs: CoroutineScope) { companion object { fun getInstance(): CodeWhispererTelemetryServiceNew = service() @@ -109,24 +111,26 @@ class CodeWhispererTelemetryServiceNew { } fun sendUserTriggerDecisionEvent(project: Project, latencyContext: LatencyContext) { - AmazonQLspService.executeIfRunning(project) { server -> - CodeWhispererServiceNew.getInstance().getAllPaginationSessions().forEach { jobId, state -> - if (state == null) return@forEach - val params = LogInlineCompletionSessionResultsParams( - sessionId = state.responseContext.sessionId, - completionSessionResult = state.recommendationContext.details.associate { - it.itemId to InlineCompletionStates( - seen = it.hasSeen, - accepted = it.isAccepted, - discarded = it.isDiscarded - ) - }, - firstCompletionDisplayLatency = latencyContext.perceivedLatency, - totalSessionDisplayTime = CodeWhispererInvocationStatus.getInstance().completionShownTime?.let { Duration.between(it, Instant.now()) } - ?.toMillis()?.toDouble(), - typeaheadLength = state.recommendationContext.userInput.length.toLong() - ) - server.logInlineCompletionSessionResults(params) + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { server -> + CodeWhispererServiceNew.getInstance().getAllPaginationSessions().forEach { jobId, state -> + if (state == null) return@forEach + val params = LogInlineCompletionSessionResultsParams( + sessionId = state.responseContext.sessionId, + completionSessionResult = state.recommendationContext.details.associate { + it.itemId to InlineCompletionStates( + seen = it.hasSeen, + accepted = it.isAccepted, + discarded = it.isDiscarded + ) + }, + firstCompletionDisplayLatency = latencyContext.perceivedLatency, + totalSessionDisplayTime = CodeWhispererInvocationStatus.getInstance().completionShownTime?.let { Duration.between(it, Instant.now()) } + ?.toMillis()?.toDouble(), + typeaheadLength = state.recommendationContext.userInput.length.toLong() + ) + server.logInlineCompletionSessionResults(params) + } } } } diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererActionTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererActionTest.kt index 5b2ede8d4d3..03aa6836078 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererActionTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererActionTest.kt @@ -23,6 +23,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doNothing import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever import software.aws.toolkits.jetbrains.services.amazonq.QConstants import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererLearnMoreAction import software.aws.toolkits.jetbrains.services.codewhisperer.actions.CodeWhispererShowSettingsAction @@ -78,7 +79,7 @@ class CodeWhispererActionTest : CodeWhispererTestBase() { @Test fun `CodeWhispererShowSettingsAction actionPerformed should show settings dialog`() { val settingsSpy = spy(ShowSettingsUtil.getInstance()) - doNothing().`when`(settingsSpy).showSettingsDialog(any(), any>()) + doNothing().whenever(settingsSpy).showSettingsDialog(any(), any>()) ApplicationManager.getApplication().replaceService(ShowSettingsUtil::class.java, settingsSpy, disposableRule.disposable) val action = CodeWhispererShowSettingsAction() runInEdtAndWait { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt index cd4784aa612..cdb183655df 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt @@ -8,6 +8,7 @@ import com.intellij.ui.dsl.builder.components.DslLabel import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.mockito.kotlin.doNothing +import org.mockito.kotlin.whenever import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererConfigurable import software.aws.toolkits.resources.message @@ -19,9 +20,9 @@ class CodeWhispererConfigurableTest : CodeWhispererTestBase() { @Test fun `test CodeWhisperer configurable`() { - doNothing().`when`(codeScanManager).buildCodeScanUI() - doNothing().`when`(codeScanManager).showCodeScanUI() - doNothing().`when`(codeScanManager).removeCodeScanUI() + doNothing().whenever(codeScanManager).buildCodeScanUI() + doNothing().whenever(codeScanManager).showCodeScanUI() + doNothing().whenever(codeScanManager).removeCodeScanUI() val configurable = CodeWhispererConfigurable(projectRule.project) // A workaround to initialize disposable in the DslConfigurableBase since somehow the disposable is diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt index 37015577346..7f19359fabc 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt @@ -13,11 +13,12 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doCallRealMethod import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.spy -import org.mockito.kotlin.stub import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionTriggerKind import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization @@ -55,10 +56,8 @@ class CodeWhispererServiceTest : CodeWhispererTestBase() { val fileContextProviderSpy = spy(fileContextProvider) projectRule.project.replaceService(FileContextProvider::class.java, fileContextProviderSpy, disposableRule.disposable) - codewhispererService.stub { - onGeneric { - getRequestContext(any(), any(), any(), any(), any()) - }.thenCallRealMethod() + doCallRealMethod().wheneverBlocking(codewhispererService) { + getRequestContext(any(), any(), any(), any(), any()) } val requestContext = codewhispererService.getRequestContext( @@ -81,7 +80,7 @@ class CodeWhispererServiceTest : CodeWhispererTestBase() { } @Test - fun `getRequestContext should have customizationArn if it's present`() { + fun `getRequestContext should have customizationArn if it's present`() = runTest { whenever(customizationConfig.activeCustomization(projectRule.project)).thenReturn( CodeWhispererCustomization( "fake-arn", @@ -95,10 +94,8 @@ class CodeWhispererServiceTest : CodeWhispererTestBase() { } projectRule.project.replaceService(FileContextProvider::class.java, mockFileContextProvider, disposableRule.disposable) - codewhispererService.stub { - onGeneric { - getRequestContext(any(), any(), any(), any(), any()) - }.thenCallRealMethod() + doCallRealMethod().wheneverBlocking(codewhispererService) { + getRequestContext(any(), any(), any(), any(), any()) } val actual = codewhispererService.getRequestContext( diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTelemetryTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTelemetryTest.kt index 430f813842a..63a8bdc653e 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTelemetryTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTelemetryTest.kt @@ -14,6 +14,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doNothing import org.mockito.kotlin.mock import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever import software.aws.toolkits.core.telemetry.MetricEvent import software.aws.toolkits.core.telemetry.TelemetryBatcher import software.aws.toolkits.core.telemetry.TelemetryPublisher @@ -51,7 +52,7 @@ class CodeWhispererTelemetryTest : CodeWhispererTestBase() { @Test fun `test toggle autoSuggestion will emit autoSuggestionActivation telemetry (popup)`() { val metricCaptor = argumentCaptor() - doNothing().`when`(batcher).enqueue(metricCaptor.capture()) + doNothing().whenever(batcher).enqueue(metricCaptor.capture()) Pause().actionPerformed(TestActionEvent { projectRule.project }) assertEventsContainsFieldsAndCount( diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt index c13a89d029a..89069f490e7 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt @@ -6,6 +6,7 @@ package software.aws.toolkits.jetbrains.services.codewhisperer import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.Editor import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer import com.intellij.psi.PsiFile import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.RuleChain @@ -13,11 +14,17 @@ import com.intellij.testFramework.replaceService import com.intellij.testFramework.runInEdtAndWait import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.jsonrpc.Launcher +import org.eclipse.lsp4j.jsonrpc.RemoteEndpoint import org.junit.After import org.junit.Before import org.junit.Rule @@ -25,10 +32,13 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.spy import org.mockito.kotlin.stub import org.mockito.kotlin.timeout import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import software.amazon.awssdk.services.ssooidc.SsoOidcClient import software.aws.toolkits.jetbrains.core.MockClientManagerRule import software.aws.toolkits.jetbrains.core.credentials.ManagedSsoProfile @@ -38,6 +48,10 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQServerInstanceFacade +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQServerInstanceStarter +import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.AwsExtendedInitializeResult import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.WorkspaceInfo import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.textDocument.InlineCompletionListWithReferences @@ -69,6 +83,7 @@ import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings import software.aws.toolkits.jetbrains.utils.rules.PythonCodeInsightTestFixtureRule import software.aws.toolkits.resources.message import java.util.concurrent.CompletableFuture +import java.util.concurrent.Future import java.util.concurrent.atomic.AtomicReference // TODO: restructure testbase, too bulky and hard to debug @@ -99,20 +114,51 @@ open class CodeWhispererTestBase { protected lateinit var codeScanManager: CodeWhispererCodeScanManager @Before - open fun setUp() { - mockLspService = spy(AmazonQLspService.getInstance(projectRule.project)) + open fun setUp() = runTest { mockLanguageServer = mockk() + val starter = object : AmazonQServerInstanceStarter { + override fun start( + project: Project, + cs: CoroutineScope, + ): AmazonQServerInstanceFacade = object : AmazonQServerInstanceFacade { + override val launcher: Launcher + get() = TODO("Not yet implemented") + + @Suppress("ForbiddenVoid") + override val launcherFuture: Future + get() = CompletableFuture() + + override val initializeResult: Deferred + get() = CompletableDeferred(AwsExtendedInitializeResult()) + + override val encryptionManager: JwtEncryptionManager + get() = TODO("Not yet implemented") + + override val languageServer: AmazonQLanguageServer + get() = mockLanguageServer + + override val rawEndpoint: RemoteEndpoint + get() = TODO("Not yet implemented") + + override fun dispose() {} + } + } + + mockLspService = spy(AmazonQLspService(starter, projectRule.project, this)) // Mock the service methods on Project projectRule.project.replaceService(AmazonQLspService::class.java, mockLspService, disposableRule.disposable) + // wait for init to finish + mockLspService.instanceFlow.first() + mockLspInlineCompletionResponse(pythonResponse) mockClientManagerRule.create() - every { mockLanguageServer.logInlineCompletionSessionResults(any()) } returns CompletableFuture.completedFuture(Unit) + every { mockLanguageServer.logInlineCompletionSessionResults(any()) } returns Unit popupManagerSpy = spy(CodeWhispererPopupManager.getInstance()) popupManagerSpy.reset() - doNothing().`when`(popupManagerSpy).showPopup(any(), any(), any(), any()) + doNothing().whenever(popupManagerSpy).showPopup(any(), any(), any(), any()) popupManagerSpy.stub { onGeneric { showPopup(any(), any(), any(), any()) @@ -125,12 +171,10 @@ open class CodeWhispererTestBase { stateManager = spy(CodeWhispererExplorerActionManager.getInstance()) recommendationManager = CodeWhispererRecommendationManager.getInstance() codewhispererService = spy(CodeWhispererService.getInstance()) - codewhispererService.stub { - onGeneric { - getWorkspaceIds(any()) - } doAnswer { - CompletableFuture.completedFuture(LspServerConfigurations(listOf(WorkspaceInfo("file:///", "workspaceId")))) - } + doAnswer { + CompletableFuture.completedFuture(LspServerConfigurations(listOf(WorkspaceInfo("file:///", "workspaceId")))) + }.wheneverBlocking(codewhispererService) { + getWorkspaceIds(any()) } ApplicationManager.getApplication().replaceService(CodeWhispererService::class.java, codewhispererService, disposableRule.disposable) editorManager = CodeWhispererEditorManager.getInstance() @@ -193,6 +237,8 @@ open class CodeWhispererTestBase { runInEdtAndWait { popupManagerSpy.closePopup() } + + Disposer.dispose(mockLspService) } fun withCodeWhispererServiceInvokedAndWait(runnable: (InvocationContext) -> Unit) { @@ -267,8 +313,19 @@ open class CodeWhispererTestBase { val projectCaptor = argumentCaptor() val psiFileCaptor = argumentCaptor() val latencyContextCaptor = argumentCaptor() - codewhispererService.stub { - onGeneric { + + doSuspendableAnswer { + val requestContext = codewhispererService.getRequestContext( + triggerTypeCaptor.firstValue, + editorCaptor.firstValue, + projectRule.project, + psiFileCaptor.firstValue, + latencyContextCaptor.firstValue + ) + projectRule.fixture.type(userInput) + requestContext + }.doCallRealMethod() + .wheneverBlocking(codewhispererService) { getRequestContext( triggerTypeCaptor.capture(), editorCaptor.capture(), @@ -276,27 +333,10 @@ open class CodeWhispererTestBase { psiFileCaptor.capture(), latencyContextCaptor.capture() ) - }.doAnswer { - val requestContext = codewhispererService.getRequestContext( - triggerTypeCaptor.firstValue, - editorCaptor.firstValue, - projectCaptor.firstValue, - psiFileCaptor.firstValue, - latencyContextCaptor.firstValue - ) - projectRule.fixture.type(userInput) - requestContext - }.thenCallRealMethod() - } + } } fun mockLspInlineCompletionResponse(response: InlineCompletionListWithReferences) { - mockLspService.stub { - onGeneric { - executeSync>(any()) - } doAnswer { - CompletableFuture.completedFuture(response) - } - } + every { mockLanguageServer.inlineCompletionWithReferences(any()) } returns CompletableFuture.completedFuture(response) } } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt index 4eeafc4cea2..82f7a296327 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt @@ -49,13 +49,13 @@ interface AmazonQLanguageClient : LanguageClient { fun getSerializedChat(params: LSPAny): CompletableFuture @JsonNotification(CHAT_SEND_UPDATE) - fun sendChatUpdate(params: LSPAny): CompletableFuture + fun sendChatUpdate(params: LSPAny) @JsonNotification(OPEN_FILE_DIFF) - fun openFileDiff(params: OpenFileDiffParams): CompletableFuture + fun openFileDiff(params: OpenFileDiffParams) @JsonNotification(CHAT_SEND_CONTEXT_COMMANDS) - fun sendContextCommands(params: LSPAny): CompletableFuture + fun sendContextCommands(params: LSPAny) @JsonNotification(CHAT_SEND_PINNED_CONTEXT) fun sendPinnedContext(params: LSPAny) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt index 07b985c96ca..aa89026ee8a 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt @@ -328,7 +328,7 @@ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageC } } - override fun sendChatUpdate(params: LSPAny): CompletableFuture { + override fun sendChatUpdate(params: LSPAny) { AsyncChatUiListener.notifyPartialMessageUpdate( project, FlareUiMessage( @@ -336,8 +336,6 @@ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageC params = params, ) ) - - return CompletableFuture.completedFuture(Unit) } private fun File.toVirtualFile() = LocalFileSystem.getInstance().findFileByIoFile(this) @@ -348,83 +346,80 @@ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageC MessageType.Info, MessageType.Log -> NotificationType.INFORMATION } - override fun openFileDiff(params: OpenFileDiffParams): CompletableFuture = - CompletableFuture.supplyAsync( - { - var tempPath: java.nio.file.Path? = null - try { - val fileName = Paths.get(params.originalFileUri).fileName.toString() - // Create a temporary virtual file for syntax highlighting - val fileExtension = fileName.substringAfterLast('.', "") - tempPath = Files.createTempFile(null, ".$fileExtension") - val virtualFile = tempPath.toFile() - .also { it.setReadOnly() } - .toVirtualFile() - - val originalContent = params.originalFileContent ?: run { - val sourceFile = File(params.originalFileUri) - if (sourceFile.exists()) sourceFile.readText() else "" + override fun openFileDiff(params: OpenFileDiffParams) { + ApplicationManager.getApplication().invokeLater { + var tempPath: java.nio.file.Path? = null + try { + val fileName = Paths.get(params.originalFileUri).fileName.toString() + // Create a temporary virtual file for syntax highlighting + val fileExtension = fileName.substringAfterLast('.', "") + tempPath = Files.createTempFile(null, ".$fileExtension") + val virtualFile = tempPath.toFile() + .also { it.setReadOnly() } + .toVirtualFile() + + val originalContent = params.originalFileContent ?: run { + val sourceFile = File(params.originalFileUri) + if (sourceFile.exists()) sourceFile.readText() else "" + } + + val contentFactory = DiffContentFactory.getInstance() + var isNewFile = false + val (leftContent, rightContent) = when { + params.isDeleted -> { + contentFactory.create(project, originalContent, virtualFile) to + contentFactory.createEmpty() } - val contentFactory = DiffContentFactory.getInstance() - var isNewFile = false - val (leftContent, rightContent) = when { - params.isDeleted -> { - contentFactory.create(project, originalContent, virtualFile) to - contentFactory.createEmpty() - } + else -> { + val newContent = params.fileContent.orEmpty() + isNewFile = newContent == originalContent + when { + isNewFile -> { + contentFactory.createEmpty() to + contentFactory.create(project, newContent, virtualFile) + } - else -> { - val newContent = params.fileContent.orEmpty() - isNewFile = newContent == originalContent - when { - isNewFile -> { - contentFactory.createEmpty() to - contentFactory.create(project, newContent, virtualFile) - } - - else -> { - contentFactory.create(project, originalContent, virtualFile) to - contentFactory.create(project, newContent, virtualFile) - } + else -> { + contentFactory.create(project, originalContent, virtualFile) to + contentFactory.create(project, newContent, virtualFile) } } } - val diffRequest = SimpleDiffRequest( - "$fileName ${message("aws.q.lsp.client.diff_message")}", - leftContent, - rightContent, - "Original", - when { - params.isDeleted -> "Deleted" - isNewFile -> "Created" - else -> "Modified" - } - ) + } + val diffRequest = SimpleDiffRequest( + "$fileName ${message("aws.q.lsp.client.diff_message")}", + leftContent, + rightContent, + "Original", + when { + params.isDeleted -> "Deleted" + isNewFile -> "Created" + else -> "Modified" + } + ) - AmazonQDiffVirtualFile.openDiff(project, diffRequest) + AmazonQDiffVirtualFile.openDiff(project, diffRequest) + } catch (e: Exception) { + LOG.warn { "Failed to open file diff: ${e.message}" } + } finally { + // Clean up the temporary file used for syntax highlight + try { + tempPath?.let { Files.deleteIfExists(it) } } catch (e: Exception) { - LOG.warn { "Failed to open file diff: ${e.message}" } - } finally { - // Clean up the temporary file used for syntax highlight - try { - tempPath?.let { Files.deleteIfExists(it) } - } catch (e: Exception) { - LOG.warn { "Failed to delete temporary file: ${e.message}" } - } + LOG.warn { "Failed to delete temporary file: ${e.message}" } } - }, - ApplicationManager.getApplication()::invokeLater - ) + } + } + } - override fun sendContextCommands(params: LSPAny): CompletableFuture { + override fun sendContextCommands(params: LSPAny) { chatManager.notifyUi( FlareUiMessage( command = CHAT_SEND_CONTEXT_COMMANDS, params = params, ) ) - return CompletableFuture.completedFuture(Unit) } override fun sendPinnedContext(params: LSPAny) { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt index 6ef5cf818ec..4b5c8faba48 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt @@ -26,16 +26,16 @@ interface AmazonQLanguageServer : LanguageServer { fun inlineCompletionWithReferences(params: InlineCompletionWithReferencesParams): CompletableFuture @JsonNotification("aws/logInlineCompletionSessionResults") - fun logInlineCompletionSessionResults(params: LogInlineCompletionSessionResultsParams): CompletableFuture + fun logInlineCompletionSessionResults(params: LogInlineCompletionSessionResultsParams) @JsonNotification("aws/didChangeDependencyPaths") - fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams): CompletableFuture + fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams) @JsonRequest("aws/credentials/token/update") fun updateTokenCredentials(payload: UpdateCredentialsPayload): CompletableFuture @JsonNotification("aws/credentials/token/delete") - fun deleteTokenCredentials(): CompletableFuture + fun deleteTokenCredentials() @JsonRequest("aws/getConfigurationFromServer") fun getConfigurationFromServer(params: GetConfigurationFromServerParams): CompletableFuture diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt index 5b3b4dd107b..6c56a6d6868 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt @@ -14,6 +14,7 @@ import com.intellij.execution.process.ProcessListener import com.intellij.execution.process.ProcessOutputType import com.intellij.notification.NotificationAction import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceIfCreated @@ -43,14 +44,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.apache.http.client.utils.URIBuilder import org.eclipse.lsp4j.ClientCapabilities import org.eclipse.lsp4j.ClientInfo -import org.eclipse.lsp4j.DidChangeConfigurationParams import org.eclipse.lsp4j.FileOperationsWorkspaceCapabilities import org.eclipse.lsp4j.InitializeParams -import org.eclipse.lsp4j.InitializeResult import org.eclipse.lsp4j.InitializedParams import org.eclipse.lsp4j.SynchronizationCapabilities import org.eclipse.lsp4j.TextDocumentClientCapabilities @@ -59,15 +59,18 @@ import org.eclipse.lsp4j.jsonrpc.Launcher import org.eclipse.lsp4j.jsonrpc.MessageConsumer import org.eclipse.lsp4j.jsonrpc.RemoteEndpoint import org.eclipse.lsp4j.jsonrpc.json.JsonRpcMethod +import org.eclipse.lsp4j.jsonrpc.json.StreamMessageConsumer +import org.eclipse.lsp4j.jsonrpc.messages.NotificationMessage +import org.eclipse.lsp4j.jsonrpc.messages.RequestMessage import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage import org.eclipse.lsp4j.launch.LSPLauncher -import org.slf4j.event.Level +import org.jetbrains.annotations.VisibleForTesting import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn import software.aws.toolkits.core.utils.writeText -import software.aws.toolkits.jetbrains.isDeveloperMode +import software.aws.toolkits.jetbrains.core.coroutines.ioDispatcher import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.DefaultModuleDependenciesService @@ -91,8 +94,6 @@ import java.io.IOException import java.io.OutputStreamWriter import java.io.PipedInputStream import java.io.PipedOutputStream -import java.io.PrintWriter -import java.io.StringWriter import java.net.Proxy import java.net.URI import java.nio.charset.StandardCharsets @@ -135,14 +136,26 @@ internal class LSPProcessListener : ProcessListener { } } +interface AmazonQServerInstanceStarter { + fun start(project: Project, cs: CoroutineScope): AmazonQServerInstanceFacade +} + +private object DefaultAmazonQServerInstanceStarter : AmazonQServerInstanceStarter { + override fun start(project: Project, cs: CoroutineScope): AmazonQServerInstanceFacade = AmazonQServerInstance(project, cs) +} + @Service(Service.Level.PROJECT) -class AmazonQLspService(private val project: Project, private val cs: CoroutineScope) : Disposable { - private val _flowInstance = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) +class AmazonQLspService @VisibleForTesting constructor( + private val starter: AmazonQServerInstanceStarter, + private val project: Project, + private val cs: CoroutineScope, +) : Disposable { + constructor(project: Project, cs: CoroutineScope) : this(DefaultAmazonQServerInstanceStarter, project, cs) + + private val _flowInstance = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val instanceFlow = _flowInstance.asSharedFlow().map { it.languageServer } - private var instance: Deferred - val capabilities - get() = instance.getCompleted().initializeResult.getCompleted().capabilities + private var instance: Deferred val encryptionManager get() = instance.getCompleted().encryptionManager @@ -156,26 +169,22 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS // dont allow lsp commands if server is restarting private val mutex = Mutex(false) - private fun start() = cs.async { + private fun start(): Deferred = cs.async { // manage lifecycle RAII-like so we can restart at arbitrary time // and suppress IDE error if server fails to start var attempts = 0 while (attempts < 3) { try { - val result = withTimeout(30.seconds) { - val instance = AmazonQServerInstance(project, cs).also { - Disposer.register(this@AmazonQLspService, it) - } - // wait for handshake to complete - instance.initializeResult.join() - - instance.also { - _flowInstance.emit(it) - } + // no timeout; start() can download which may take long time + val instance = starter.start(project, cs).also { + Disposer.register(this@AmazonQLspService, it) } + // wait for handshake to complete + instance.initializeResult.join() - // withTimeout can throw - return@async result + return@async instance.also { + _flowInstance.emit(it) + } } catch (e: Exception) { LOG.warn(e) { "Failed to start LSP server" } } @@ -190,6 +199,9 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS // Initialize heartbeat job heartbeatJob = cs.launch { + if (ApplicationManager.getApplication().isUnitTestMode) { + return@launch + } while (isActive) { delay(5.seconds) // Check every 5 seconds val shouldLoop = checkConnectionStatus() @@ -277,7 +289,7 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS } suspend fun execute(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T { - val lsp = withTimeout(10.seconds) { + val lsp = withTimeout(5.seconds) { val holder = mutex.withLock { instance }.await() holder.initializeResult.join() @@ -286,47 +298,64 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS return runnable(lsp) } - fun executeSync(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T = - runBlocking(cs.coroutineContext) { - execute(runnable) + suspend fun executeIfRunning(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T? = withContext(dispatcher) { + val lsp = try { + withTimeout(5.seconds) { + val holder = mutex.withLock { instance }.await() + holder.initializeResult.join() + + holder.languageServer + } + } catch (_: Exception) { + LOG.debug { "LSP not running" } + + null + } + + lsp?.let { runnable(it) } + } + + fun syncExecuteIfRunning(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T? = + runBlocking(dispatcher) { + executeIfRunning(runnable) } + internal val dispatcher = ioDispatcher(20) + companion object { private val LOG = getLogger() private const val MAX_RESTARTS = 5 private const val RESTART_WINDOW_MS = 3 * 60 * 1000 fun getInstance(project: Project) = project.service() - @Deprecated("Easy to accidentally freeze EDT") - fun executeIfRunning(project: Project, runnable: AmazonQLspService.(AmazonQLanguageServer) -> T): T? = - project.serviceIfCreated()?.executeSync(runnable) - - suspend fun asyncExecuteIfRunning(project: Project, runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T? = - project.serviceIfCreated()?.execute(runnable) - - fun didChangeConfiguration(project: Project) { - executeIfRunning(project) { - it.workspaceService.didChangeConfiguration(DidChangeConfigurationParams()) - } - } + suspend fun executeAsyncIfRunning(project: Project, runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T? = + project.serviceIfCreated()?.executeIfRunning(runnable) } } -private class AmazonQServerInstance(private val project: Project, private val cs: CoroutineScope) : Disposable { - val encryptionManager = JwtEncryptionManager() +interface AmazonQServerInstanceFacade : Disposable { + val launcher: Launcher - private val launcher: Launcher + @Suppress("ForbiddenVoid") + val launcherFuture: Future + val initializeResult: Deferred + val encryptionManager: JwtEncryptionManager val languageServer: AmazonQLanguageServer get() = launcher.remoteProxy val rawEndpoint: RemoteEndpoint get() = launcher.remoteEndpoint +} + +private class AmazonQServerInstance(private val project: Project, private val cs: CoroutineScope) : Disposable, AmazonQServerInstanceFacade { + override val encryptionManager = JwtEncryptionManager() + override val launcher: Launcher @Suppress("ForbiddenVoid") - val launcherFuture: Future + override val launcherFuture: Future private val launcherHandler: KillableProcessHandler - val initializeResult: Deferred + override val initializeResult: Deferred private fun createClientCapabilities(): ClientCapabilities = ClientCapabilities().apply { @@ -479,34 +508,45 @@ private class AmazonQServerInstance(private val project: Project, private val cs } .wrapMessages { consumer -> MessageConsumer { message -> + // logging + // add "software.aws.toolkits.jetbrains.services.amazonq.lsp:separate" to "Debug Log Settings" + LOG.debug { + val direction = when (consumer) { + is StreamMessageConsumer -> "Sending" + is RemoteEndpoint -> "Receiving" + else -> "Unknown direction" + } + + when (message) { + is ResponseMessage -> { + "$direction: ${message.result}" + } + is RequestMessage -> { + "$direction: ${message.method}" + } + is NotificationMessage -> { + "$direction: ${message.method}" + } + else -> "$direction: $message" + } + } + if (message is ResponseMessage && message.result is AwsExtendedInitializeResult) { val result = message.result as AwsExtendedInitializeResult AwsServerCapabilitiesProvider.getInstance(project).setAwsServerCapabilities(result.getAwsServerCapabilities()) } + + // required consumer?.consume(message) } } .setLocalService(AmazonQLanguageClientImpl(project)) .setRemoteInterface(AmazonQLanguageServer::class.java) .configureGson { - // TODO: maybe need adapter for initialize: - // https://github.com/aws/amazon-q-eclipse/blob/b9d5bdcd5c38e1dd8ad371d37ab93a16113d7d4b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QLspTypeAdapterFactory.java - // otherwise Gson treats all numbers as double which causes deser issues it.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) it.registerTypeAdapterFactory(AmazonQLspTypeAdapterFactory()) - }.traceMessages( - PrintWriter( - object : StringWriter() { - private val traceLogger = LOG.atLevel(if (isDeveloperMode()) Level.INFO else Level.DEBUG) - - override fun flush() { - traceLogger.log { buffer.toString() } - buffer.setLength(0) - } - } - ) - ) + } .setInput(inputWrapper.inputStream) .setOutput(launcherHandler.process.outputStream) .create() @@ -531,7 +571,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs } languageServer.initialized(InitializedParams()) - initializeResult + initializeResult as AwsExtendedInitializeResult } // invokeOnCompletion results in weird lock/timeout error @@ -541,16 +581,16 @@ private class AmazonQServerInstance(private val project: Project, private val cs } this@AmazonQServerInstance.apply { - DefaultAuthCredentialsService(project, encryptionManager).also { + DefaultAuthCredentialsService(project, encryptionManager, cs).also { Disposer.register(this, it) } - TextDocumentServiceHandler(project).also { + TextDocumentServiceHandler(project, cs).also { Disposer.register(this, it) } - WorkspaceServiceHandler(project, lspInitResult).also { + WorkspaceServiceHandler(project, cs, lspInitResult).also { Disposer.register(this, it) } - DefaultModuleDependenciesService(project).also { + DefaultModuleDependenciesService(project, cs).also { Disposer.register(this, it) } } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt index fb40fb75f35..e2966004421 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt @@ -9,5 +9,5 @@ import java.util.concurrent.CompletableFuture interface AuthCredentialsService { fun updateTokenCredentials(connection: ToolkitConnection, encrypted: Boolean): CompletableFuture - fun deleteTokenCredentials(): CompletableFuture + fun deleteTokenCredentials() } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt index 57054467b9d..de03c0e7fa8 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt @@ -6,6 +6,11 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.util.concurrency.AppExecutorUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage import software.aws.toolkits.core.TokenConnectionSettings import software.aws.toolkits.core.utils.getLogger @@ -19,7 +24,6 @@ import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenPr import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.UpdateConfigurationParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.BearerCredentials import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata @@ -38,6 +42,7 @@ import java.util.concurrent.TimeUnit class DefaultAuthCredentialsService( private val project: Project, private val encryptionManager: JwtEncryptionManager, + private val cs: CoroutineScope, ) : AuthCredentialsService, BearerTokenProviderListener, ToolkitConnectionManagerListener, @@ -111,23 +116,26 @@ class DefaultAuthCredentialsService( return CompletableFuture.failedFuture(e) } - val future = AmazonQLspService.executeIfRunning(project) { server -> - server.updateTokenCredentials(payload) - } ?: CompletableFuture.failedFuture(IllegalStateException("LSP Server not running")) + return cs.async { + val result = AmazonQLspService.executeAsyncIfRunning(project) { server -> + server.updateTokenCredentials(payload) + } ?: CompletableFuture.failedFuture(IllegalStateException("LSP Server not running")) - return future.thenApply { response -> - updateConfiguration() - response - } + result.thenApply { response -> + updateConfiguration() + + response + }.await() + }.asCompletableFuture() } - override fun deleteTokenCredentials(): CompletableFuture = - CompletableFuture().also { completableFuture -> - AmazonQLspService.executeIfRunning(project) { server -> + override fun deleteTokenCredentials() { + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { server -> server.deleteTokenCredentials() - completableFuture.complete(null) - } ?: completableFuture.completeExceptionally(IllegalStateException("LSP Server not running")) + } } + } override fun onChange(providerId: String, newScopes: List?) { updateTokenFromActiveConnection() @@ -189,16 +197,18 @@ class DefaultAuthCredentialsService( updateConfiguration() } - private fun updateConfiguration(): CompletableFuture { - val payload = UpdateConfigurationParams( - section = "aws.q", - settings = mapOf( - "profileArn" to QRegionProfileManager.getInstance().activeProfile(project)?.arn + private fun updateConfiguration() { + cs.launch { + val payload = UpdateConfigurationParams( + section = "aws.q", + settings = mapOf( + "profileArn" to QRegionProfileManager.getInstance().activeProfile(project)?.arn + ) ) - ) - return AmazonQLspService.executeIfRunning(project) { server -> - server.updateConfiguration(payload) - } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) + AmazonQLspService.executeAsyncIfRunning(project) { server -> + server.updateConfiguration(payload) + } + } } override fun dispose() { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt index 4f3f4ac8bce..f4f31165d7a 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt @@ -8,13 +8,15 @@ import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ModuleRootEvent import com.intellij.openapi.roots.ModuleRootListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider.Companion.EP_NAME import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams -import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread class DefaultModuleDependenciesService( private val project: Project, + private val cs: CoroutineScope, ) : ModuleDependenciesService, ModuleRootListener, Disposable { @@ -34,8 +36,8 @@ class DefaultModuleDependenciesService( } override fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams) { - AmazonQLspService.executeIfRunning(project) { languageServer -> - pluginAwareExecuteOnPooledThread { + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> languageServer.didChangeDependencyPaths(params) } } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt index 387ee603557..1bf868fde88 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt @@ -22,6 +22,8 @@ import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.newvfs.BulkFileListener import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.eclipse.lsp4j.DidChangeTextDocumentParams import org.eclipse.lsp4j.DidCloseTextDocumentParams import org.eclipse.lsp4j.DidOpenTextDocumentParams @@ -34,10 +36,10 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ACTIVE_EDITOR_CHANGED_NOTIFICATION import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.getCursorState import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString -import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread class TextDocumentServiceHandler( private val project: Project, + private val cs: CoroutineScope, ) : FileDocumentManagerListener, FileEditorManagerListener, BulkFileListener, @@ -81,9 +83,10 @@ class TextDocumentServiceHandler( file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, listener) } } - AmazonQLspService.executeIfRunning(project) { languageServer -> - toUriString(file)?.let { uri -> - pluginAwareExecuteOnPooledThread { + + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> + toUriString(file)?.let { uri -> languageServer.textDocumentService.didOpen( DidOpenTextDocumentParams().apply { textDocument = TextDocumentItem().apply { @@ -100,10 +103,10 @@ class TextDocumentServiceHandler( } override fun beforeDocumentSaving(document: Document) { - AmazonQLspService.executeIfRunning(project) { languageServer -> - val file = FileDocumentManager.getInstance().getFile(document) ?: return@executeIfRunning - toUriString(file)?.let { uri -> - pluginAwareExecuteOnPooledThread { + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> + val file = FileDocumentManager.getInstance().getFile(document) ?: return@executeAsyncIfRunning + toUriString(file)?.let { uri -> languageServer.textDocumentService.didSave( DidSaveTextDocumentParams().apply { textDocument = TextDocumentIdentifier().apply { @@ -118,8 +121,8 @@ class TextDocumentServiceHandler( } override fun after(events: MutableList) { - AmazonQLspService.executeIfRunning(project) { languageServer -> - pluginAwareExecuteOnPooledThread { + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> events.filterIsInstance().forEach { event -> val document = FileDocumentManager.getInstance().getCachedDocument(event.file) ?: return@forEach toUriString(event.file)?.let { uri -> @@ -158,15 +161,17 @@ class TextDocumentServiceHandler( FileDocumentManager.getInstance().getDocument(file)?.removeDocumentListener(listener) file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, null) } - AmazonQLspService.executeIfRunning(project) { languageServer -> - toUriString(file)?.let { uri -> - languageServer.textDocumentService.didClose( - DidCloseTextDocumentParams().apply { - textDocument = TextDocumentIdentifier().apply { - this.uri = uri + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didClose( + DidCloseTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } } - } - ) + ) + } } } } @@ -187,15 +192,17 @@ class TextDocumentServiceHandler( ) // Send notification to the language server - AmazonQLspService.executeIfRunning(project) { _ -> - rawEndpoint.notify(ACTIVE_EDITOR_CHANGED_NOTIFICATION, params) + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { _ -> + rawEndpoint.notify(ACTIVE_EDITOR_CHANGED_NOTIFICATION, params) + } } } private fun realTimeEdit(event: DocumentEvent) { - AmazonQLspService.executeIfRunning(project) { languageServer -> - pluginAwareExecuteOnPooledThread { - val vFile = FileDocumentManager.getInstance().getFile(event.document) ?: return@pluginAwareExecuteOnPooledThread + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> + val vFile = FileDocumentManager.getInstance().getFile(event.document) ?: return@executeAsyncIfRunning toUriString(vFile)?.let { uri -> languageServer.textDocumentService.didChange( DidChangeTextDocumentParams().apply { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt index 906b24481ef..8a644cdb5e4 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt @@ -16,6 +16,8 @@ import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent import com.intellij.openapi.vfs.newvfs.events.VFileEvent import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.eclipse.lsp4j.CreateFilesParams import org.eclipse.lsp4j.DeleteFilesParams import org.eclipse.lsp4j.DidChangeWatchedFilesParams @@ -37,13 +39,13 @@ import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders -import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread import java.nio.file.FileSystems import java.nio.file.PathMatcher import java.nio.file.Paths class WorkspaceServiceHandler( private val project: Project, + private val cs: CoroutineScope, initializeResult: InitializeResult, ) : BulkFileListener, ModuleRootListener, @@ -100,8 +102,8 @@ class WorkspaceServiceHandler( } } - private fun didCreateFiles(events: List) { - AmazonQLspService.executeIfRunning(project) { languageServer -> + private suspend fun didCreateFiles(events: List) { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> val validFiles = events.mapNotNull { event -> when (event) { is VFileCopyEvent -> { @@ -135,8 +137,8 @@ class WorkspaceServiceHandler( } } - private fun didDeleteFiles(events: List) { - AmazonQLspService.executeIfRunning(project) { languageServer -> + private suspend fun didDeleteFiles(events: List) { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> val validFiles = events.mapNotNull { event -> when (event) { is VFileDeleteEvent -> { @@ -165,8 +167,8 @@ class WorkspaceServiceHandler( } } - private fun didRenameFiles(events: List) { - AmazonQLspService.executeIfRunning(project) { languageServer -> + private suspend fun didRenameFiles(events: List) { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> val validRenames = events .filter { it.propertyName == VirtualFile.PROP_NAME } .mapNotNull { event -> @@ -218,8 +220,8 @@ class WorkspaceServiceHandler( } } - private fun didChangeWatchedFiles(events: List) { - AmazonQLspService.executeIfRunning(project) { languageServer -> + private suspend fun didChangeWatchedFiles(events: List) { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> val validChanges = events.flatMap { event -> when (event) { is VFileCopyEvent -> { @@ -281,7 +283,7 @@ class WorkspaceServiceHandler( override fun after(events: List) { // since we are using synchronous FileListener - pluginAwareExecuteOnPooledThread { + cs.launch { didCreateFiles(events.filter { it is VFileCreateEvent || it is VFileMoveEvent || it is VFileCopyEvent }) didDeleteFiles(events.filter { it is VFileMoveEvent || it is VFileDeleteEvent }) didRenameFiles(events.filterIsInstance()) @@ -294,23 +296,25 @@ class WorkspaceServiceHandler( } override fun rootsChanged(event: ModuleRootEvent) { - AmazonQLspService.executeIfRunning(project) { languageServer -> - val currentSnapshot = createWorkspaceFolders(project) - val addedFolders = currentSnapshot.filter { folder -> lastSnapshot.none { it.uri == folder.uri } } - val removedFolders = lastSnapshot.filter { folder -> currentSnapshot.none { it.uri == folder.uri } } + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> + val currentSnapshot = createWorkspaceFolders(project) + val addedFolders = currentSnapshot.filter { folder -> lastSnapshot.none { it.uri == folder.uri } } + val removedFolders = lastSnapshot.filter { folder -> currentSnapshot.none { it.uri == folder.uri } } - if (addedFolders.isNotEmpty() || removedFolders.isNotEmpty()) { - languageServer.workspaceService.didChangeWorkspaceFolders( - DidChangeWorkspaceFoldersParams().apply { - this.event = WorkspaceFoldersChangeEvent().apply { - added = addedFolders - removed = removedFolders + if (addedFolders.isNotEmpty() || removedFolders.isNotEmpty()) { + languageServer.workspaceService.didChangeWorkspaceFolders( + DidChangeWorkspaceFoldersParams().apply { + this.event = WorkspaceFoldersChangeEvent().apply { + added = addedFolders + removed = removedFolders + } } - } - ) - } + ) + } - lastSnapshot = currentSnapshot + lastSnapshot = currentSnapshot + } } } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt index 3b9d6d8c0fe..aef0428732a 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt @@ -4,12 +4,13 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth import com.intellij.openapi.Disposable -import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceIfCreated import com.intellij.openapi.project.Project import com.intellij.testFramework.ProjectExtension +import com.intellij.testFramework.replaceService import com.intellij.util.messages.MessageBus import com.intellij.util.messages.MessageBusConnection +import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -17,6 +18,8 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.spyk import io.mockk.verify +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -26,10 +29,12 @@ import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.sso.PKCEAuthorizationGrantToken +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.InteractiveBearerTokenProvider import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload @@ -69,8 +74,8 @@ class DefaultAuthCredentialsServiceTest { } val mockLspService = mockk() - every { - mockLspService.executeSync>(any()) + coEvery { + mockLspService.executeIfRunning>(any()) } coAnswers { val func = firstArg CompletableFuture>() func.invoke(mockLspService, mockLanguageServer) @@ -78,11 +83,15 @@ class DefaultAuthCredentialsServiceTest { every { mockLanguageServer.updateTokenCredentials(any()) - } returns CompletableFuture() + } returns CompletableFuture.completedFuture(ResponseMessage()) every { mockLanguageServer.deleteTokenCredentials() - } returns CompletableFuture.completedFuture(Unit) + } returns Unit + + every { + mockLanguageServer.updateConfiguration(any()) + } returns CompletableFuture.completedFuture(LspServerConfigurations(emptyList())) every { project.getService(AmazonQLspService::class.java) } returns mockLspService every { project.serviceIfCreated() } returns mockLspService @@ -101,8 +110,9 @@ class DefaultAuthCredentialsServiceTest { mockConnection = createMockConnection(accessToken) mockConnectionManager = mockk { every { activeConnectionForFeature(any()) } returns mockConnection + every { connectionStateForFeature(any()) } returns BearerTokenAuthState.AUTHORIZED } - every { project.service() } returns mockConnectionManager + project.replaceService(ToolkitConnectionManager::class.java, mockConnectionManager, project) mockkStatic("software.aws.toolkits.jetbrains.utils.FunctionUtilsKt") // these set so init doesn't always emit every { isQConnected(any()) } returns false @@ -142,64 +152,70 @@ class DefaultAuthCredentialsServiceTest { } @Test - fun `activeConnectionChanged updates token when connection ID matches Q connection`() { - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + fun `activeConnectionChanged updates token when connection ID matches Q connection`() = runTest { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) val newConnection = createMockConnection("new-token", "connection-id") every { mockConnection.id } returns "connection-id" sut.activeConnectionChanged(newConnection) + advanceUntilIdle() verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } } @Test - fun `activeConnectionChanged does not update token when connection ID differs`() { - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + fun `activeConnectionChanged does not update token when connection ID differs`() = runTest { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) val newConnection = createMockConnection("new-token", "different-id") every { mockConnection.id } returns "q-connection-id" sut.activeConnectionChanged(newConnection) + advanceUntilIdle() verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } } @Test - fun `onChange updates token with new connection`() { - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + fun `onChange updates token with new connection`() = runTest { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) setupMockConnectionManager("updated-token") sut.onChange("providerId", listOf("new-scope")) + advanceUntilIdle() verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } } @Test - fun `init does not update token when Q is not connected`() { + fun `init does not update token when Q is not connected`() = runTest { every { isQConnected(project) } returns false every { isQExpired(project) } returns false - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) + advanceUntilIdle() verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } } @Test - fun `init does not update token when Q is expired`() { + fun `init does not update token when Q is expired`() = runTest { every { isQConnected(project) } returns true every { isQExpired(project) } returns true - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) + advanceUntilIdle() verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } } @Test - fun `test updateTokenCredentials unencrypted success`() { + fun `test updateTokenCredentials unencrypted success`() = runTest { val isEncrypted = false - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) sut.updateTokenCredentials(mockConnection, isEncrypted) + advanceUntilIdle() verify(exactly = 1) { mockLanguageServer.updateTokenCredentials( UpdateCredentialsPayload( @@ -214,8 +230,8 @@ class DefaultAuthCredentialsServiceTest { } @Test - fun `test updateTokenCredentials encrypted success`() { - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + fun `test updateTokenCredentials encrypted success`() = runTest { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) val encryptedToken = "encryptedToken" val isEncrypted = true @@ -224,6 +240,7 @@ class DefaultAuthCredentialsServiceTest { sut.updateTokenCredentials(mockConnection, isEncrypted) + advanceUntilIdle() verify(atLeast = 1) { mockLanguageServer.updateTokenCredentials( UpdateCredentialsPayload( @@ -238,22 +255,24 @@ class DefaultAuthCredentialsServiceTest { } @Test - fun `test deleteTokenCredentials success`() { - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + fun `test deleteTokenCredentials success`() = runTest { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) - every { mockLanguageServer.deleteTokenCredentials() } returns CompletableFuture.completedFuture(Unit) + every { mockLanguageServer.deleteTokenCredentials() } returns Unit sut.deleteTokenCredentials() + advanceUntilIdle() verify(exactly = 1) { mockLanguageServer.deleteTokenCredentials() } } @Test - fun `init results in token update`() { + fun `init results in token update`() = runTest { every { isQConnected(any()) } returns true every { isQExpired(any()) } returns false - sut = DefaultAuthCredentialsService(project, mockEncryptionManager) + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, this) + advanceUntilIdle() verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } } } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt index 28e73b99446..cce9cbe3e0d 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt @@ -12,6 +12,7 @@ import com.intellij.openapi.roots.ModuleRootEvent import com.intellij.testFramework.ApplicationExtension import com.intellij.util.messages.MessageBus import com.intellij.util.messages.MessageBusConnection +import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -19,6 +20,8 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -45,7 +48,7 @@ class DefaultModuleDependenciesServiceTest { mockDependencyProvider = mockk() mockLanguageServer = mockk() - every { mockLanguageServer.didChangeDependencyPaths(any()) } returns CompletableFuture() + every { mockLanguageServer.didChangeDependencyPaths(any()) } returns Unit // Mock message bus val messageBus = mockk() @@ -63,8 +66,8 @@ class DefaultModuleDependenciesServiceTest { val mockLspService = mockk() every { project.getService(AmazonQLspService::class.java) } returns mockLspService every { project.serviceIfCreated() } returns mockLspService - every { - mockLspService.executeSync>(any()) + coEvery { + mockLspService.executeIfRunning>(any()) } coAnswers { val func = firstArg CompletableFuture>() func.invoke(mockLspService, mockLanguageServer) @@ -81,7 +84,7 @@ class DefaultModuleDependenciesServiceTest { } @Test - fun `test initial sync on construction`() { + fun `test initial sync on construction`() = runTest { // Arrange val module = mockk() val params = DidChangeDependencyPathsParams( @@ -95,13 +98,14 @@ class DefaultModuleDependenciesServiceTest { every { mockModuleManager.modules } returns arrayOf(module) prepDependencyProvider(listOf(Pair(module, params))) - sut = DefaultModuleDependenciesService(project) + sut = DefaultModuleDependenciesService(project, this) + advanceUntilIdle() verify { mockLanguageServer.didChangeDependencyPaths(params) } } @Test - fun `test rootsChanged with multiple modules`() { + fun `test rootsChanged with multiple modules`() = runTest { // Arrange val module1 = mockk() val module2 = mockk() @@ -127,14 +131,15 @@ class DefaultModuleDependenciesServiceTest { ) ) - sut = DefaultModuleDependenciesService(project) + sut = DefaultModuleDependenciesService(project, this) + advanceUntilIdle() verify { mockLanguageServer.didChangeDependencyPaths(params1) } verify { mockLanguageServer.didChangeDependencyPaths(params2) } } @Test - fun `test rootsChanged withFileTypesChange`() { + fun `test rootsChanged withFileTypesChange`() = runTest { // Arrange val module = mockk() val params = DidChangeDependencyPathsParams( @@ -148,15 +153,16 @@ class DefaultModuleDependenciesServiceTest { val event = mockk() every { event.isCausedByFileTypesChange } returns true - sut = DefaultModuleDependenciesService(project) + sut = DefaultModuleDependenciesService(project, this) sut.rootsChanged(event) + advanceUntilIdle() verify(exactly = 1) { mockLanguageServer.didChangeDependencyPaths(params) } } @Test - fun `test rootsChanged after module changes`() { + fun `test rootsChanged after module changes`() = runTest { // Arrange val module = mockk() val params = DidChangeDependencyPathsParams( @@ -173,10 +179,11 @@ class DefaultModuleDependenciesServiceTest { prepDependencyProvider(listOf(Pair(module, params))) - sut = DefaultModuleDependenciesService(project) + sut = DefaultModuleDependenciesService(project, this) sut.rootsChanged(event) + advanceUntilIdle() verify(exactly = 2) { mockLanguageServer.didChangeDependencyPaths(params) } } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt index 1af72733694..616cf72812a 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt @@ -16,6 +16,7 @@ import com.intellij.testFramework.LightVirtualFile import com.intellij.testFramework.fixtures.CodeInsightTestFixture import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory import com.intellij.testFramework.replaceService +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -23,6 +24,8 @@ import io.mockk.mockkStatic import io.mockk.slot import io.mockk.spyk import io.mockk.verify +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import org.assertj.core.api.Assertions.assertThat @@ -51,6 +54,9 @@ class TextDocumentServiceHandlerTest { private lateinit var mockTextDocumentService: TextDocumentService private lateinit var sut: TextDocumentServiceHandler + // not ideal + private lateinit var testScope: TestScope + @get:Rule val projectRule = object : CodeInsightTestFixtureRule() { override fun createTestFixture(): CodeInsightTestFixture { @@ -80,8 +86,8 @@ class TextDocumentServiceHandlerTest { projectRule.project.replaceService(AmazonQLspService::class.java, mockLspService, disposableRule.disposable) // Mock the LSP service's executeSync method as a suspend function - every { - mockLspService.executeSync>(any()) + coEvery { + mockLspService.executeIfRunning>(any()) } coAnswers { val func = firstArg CompletableFuture>() func.invoke(mockLspService, mockLanguageServer) @@ -94,11 +100,12 @@ class TextDocumentServiceHandlerTest { every { mockTextDocumentService.didOpen(any()) } returns Unit every { mockTextDocumentService.didClose(any()) } returns Unit - sut = TextDocumentServiceHandler(projectRule.project) + testScope = TestScope() + sut = TextDocumentServiceHandler(projectRule.project, testScope) } @Test - fun `didSave runs on beforeDocumentSaving`() = runTest { + fun `didSave runs on beforeDocumentSaving`() { // Create test document and file val uri = URI.create("file:///test/path/file.txt") val document = mockk { @@ -120,6 +127,7 @@ class TextDocumentServiceHandlerTest { sut.beforeDocumentSaving(document) // Verify the correct LSP method was called with matching parameters + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockTextDocumentService.didSave(capture(paramsSlot)) } @@ -137,8 +145,9 @@ class TextDocumentServiceHandlerTest { projectRule.fixture.createFile("name", content).also { projectRule.fixture.openFileInEditor(it) } } - sut = TextDocumentServiceHandler(projectRule.project) + sut = TextDocumentServiceHandler(projectRule.project, this) + advanceUntilIdle() val paramsSlot = mutableListOf() verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } @@ -158,6 +167,8 @@ class TextDocumentServiceHandlerTest { sut.fileOpened(mockk(), file) + advanceUntilIdle() + testScope.advanceUntilIdle() val paramsSlot = mutableListOf() verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } @@ -175,6 +186,8 @@ class TextDocumentServiceHandlerTest { sut.fileClosed(mockk(), file) + advanceUntilIdle() + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockTextDocumentService.didClose(capture(paramsSlot)) } @@ -194,6 +207,8 @@ class TextDocumentServiceHandlerTest { } // Verify the correct LSP method was called with matching parameters + advanceUntilIdle() + testScope.advanceUntilIdle() val paramsSlot = mutableListOf() verify { mockTextDocumentService.didChange(capture(paramsSlot)) } @@ -220,6 +235,7 @@ class TextDocumentServiceHandlerTest { sut.beforeDocumentSaving(document) + testScope.advanceUntilIdle() verify(exactly = 0) { mockTextDocumentService.didSave(any()) } } } @@ -238,6 +254,7 @@ class TextDocumentServiceHandlerTest { sut.beforeDocumentSaving(document) + testScope.advanceUntilIdle() verify(exactly = 0) { mockTextDocumentService.didSave(any()) } } } @@ -248,6 +265,7 @@ class TextDocumentServiceHandlerTest { sut.after(mutableListOf(nonContentEvent)) + testScope.advanceUntilIdle() verify(exactly = 0) { mockTextDocumentService.didChange(any()) } } @@ -273,6 +291,7 @@ class TextDocumentServiceHandlerTest { sut.after(mutableListOf(changeEvent)) + testScope.advanceUntilIdle() verify(exactly = 0) { mockTextDocumentService.didChange(any()) } } } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt index ffa6b3e7af1..ee7bd0059d0 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt @@ -18,6 +18,7 @@ import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent import com.intellij.util.messages.MessageBus import com.intellij.util.messages.MessageBusConnection +import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -26,7 +27,8 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.slot import io.mockk.verify -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import org.assertj.core.api.Assertions.assertThat import org.eclipse.lsp4j.CreateFilesParams import org.eclipse.lsp4j.DeleteFilesParams @@ -66,6 +68,9 @@ class WorkspaceServiceHandlerTest { private lateinit var mockTextDocumentService: TextDocumentService private lateinit var sut: WorkspaceServiceHandler + // not ideal + private lateinit var testScope: TestScope + @BeforeEach fun setup() { project = mockk() @@ -88,8 +93,8 @@ class WorkspaceServiceHandlerTest { every { project.serviceIfCreated() } returns mockLspService // Mock the LSP service's executeSync method as a suspend function - every { - mockLspService.executeSync>(any()) + coEvery { + mockLspService.executeIfRunning>(any()) } coAnswers { val func = firstArg CompletableFuture>() func.invoke(mockLspService, mockLanguageServer) @@ -146,177 +151,192 @@ class WorkspaceServiceHandlerTest { every { mockInitializeResult.capabilities } returns mockCapabilities // Create WorkspaceServiceHandler with mocked InitializeResult - sut = WorkspaceServiceHandler(project, mockInitializeResult) + testScope = TestScope() + sut = WorkspaceServiceHandler(project, testScope, mockInitializeResult) } @Test - fun `test didCreateFiles with Python file`() = runTest { + fun `test didCreateFiles with Python file`() { val pyUri = URI("file:///test/path") val pyEvent = createMockVFileEvent(pyUri, FileChangeType.Created, false, "py") sut.after(listOf(pyEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(pyUri.toString())) } @Test - fun `test didCreateFiles with TypeScript file`() = runTest { + fun `test didCreateFiles with TypeScript file`() { val tsUri = URI("file:///test/path") val tsEvent = createMockVFileEvent(tsUri, FileChangeType.Created, false, "ts") sut.after(listOf(tsEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(tsUri.toString())) } @Test - fun `test didCreateFiles with JavaScript file`() = runTest { + fun `test didCreateFiles with JavaScript file`() { val jsUri = URI("file:///test/path") val jsEvent = createMockVFileEvent(jsUri, FileChangeType.Created, false, "js") sut.after(listOf(jsEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(jsUri.toString())) } @Test - fun `test didCreateFiles with Java file`() = runTest { + fun `test didCreateFiles with Java file`() { val javaUri = URI("file:///test/path") val javaEvent = createMockVFileEvent(javaUri, FileChangeType.Created, false, "java") sut.after(listOf(javaEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(javaUri.toString())) } @Test - fun `test didCreateFiles called for directory`() = runTest { + fun `test didCreateFiles called for directory`() { val dirUri = URI("file:///test/directory/path") val dirEvent = createMockVFileEvent(dirUri, FileChangeType.Created, true, "") sut.after(listOf(dirEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(dirUri.toString())) } @Test - fun `test didCreateFiles not called for unsupported file extension`() = runTest { + fun `test didCreateFiles not called for unsupported file extension`() { val txtUri = URI("file:///test/path") val txtEvent = createMockVFileEvent(txtUri, FileChangeType.Created, false, "txt") sut.after(listOf(txtEvent)) + testScope.advanceUntilIdle() verify(exactly = 0) { mockWorkspaceService.didCreateFiles(any()) } } @Test - fun `test didCreateFiles with move event`() = runTest { + fun `test didCreateFiles with move event`() { val oldUri = URI("file:///test/oldPath") val newUri = URI("file:///test/newPath") val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.py") sut.after(listOf(moveEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) } @Test - fun `test didCreateFiles with copy event`() = runTest { + fun `test didCreateFiles with copy event`() { val originalUri = URI("file:///test/original") val newUri = URI("file:///test/new") val copyEvent = createMockVFileCopyEvent(originalUri, newUri, "test.py") sut.after(listOf(copyEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) } @Test - fun `test didDeleteFiles with Python file`() = runTest { + fun `test didDeleteFiles with Python file`() { val pyUri = URI("file:///test/path") val pyEvent = createMockVFileEvent(pyUri, FileChangeType.Deleted, false, "py") sut.after(listOf(pyEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(pyUri.toString())) } @Test - fun `test didDeleteFiles with TypeScript file`() = runTest { + fun `test didDeleteFiles with TypeScript file`() { val tsUri = URI("file:///test/path") val tsEvent = createMockVFileEvent(tsUri, FileChangeType.Deleted, false, "ts") sut.after(listOf(tsEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(tsUri.toString())) } @Test - fun `test didDeleteFiles with JavaScript file`() = runTest { + fun `test didDeleteFiles with JavaScript file`() { val jsUri = URI("file:///test/path") val jsEvent = createMockVFileEvent(jsUri, FileChangeType.Deleted, false, "js") sut.after(listOf(jsEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(jsUri.toString())) } @Test - fun `test didDeleteFiles with Java file`() = runTest { + fun `test didDeleteFiles with Java file`() { val javaUri = URI("file:///test/path") val javaEvent = createMockVFileEvent(javaUri, FileChangeType.Deleted, false, "java") sut.after(listOf(javaEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(javaUri.toString())) } @Test - fun `test didDeleteFiles not called for unsupported file extension`() = runTest { + fun `test didDeleteFiles not called for unsupported file extension`() { val txtUri = URI("file:///test/path") val txtEvent = createMockVFileEvent(txtUri, FileChangeType.Deleted, false, "txt") sut.after(listOf(txtEvent)) + testScope.advanceUntilIdle() verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } } @Test - fun `test didDeleteFiles called for directory`() = runTest { + fun `test didDeleteFiles called for directory`() { val dirUri = URI("file:///test/directory/path") val dirEvent = createMockVFileEvent(dirUri, FileChangeType.Deleted, true, "") sut.after(listOf(dirEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(dirUri.toString())) } @Test - fun `test didDeleteFiles handles both delete and move events in same batch`() = runTest { + fun `test didDeleteFiles handles both delete and move events in same batch`() { val deleteUri = URI("file:///test/deleteFile") val oldMoveUri = URI("file:///test/oldMoveFile") val newMoveUri = URI("file:///test/newMoveFile") @@ -326,6 +346,7 @@ class WorkspaceServiceHandlerTest { sut.after(listOf(deleteEvent, moveEvent)) + testScope.advanceUntilIdle() val deleteParamsSlot = slot() verify { mockWorkspaceService.didDeleteFiles(capture(deleteParamsSlot)) } assertThat(deleteParamsSlot.captured.files).hasSize(2) @@ -334,31 +355,33 @@ class WorkspaceServiceHandlerTest { } @Test - fun `test didDeleteFiles with move event of unsupported file type`() = runTest { + fun `test didDeleteFiles with move event of unsupported file type`() { val oldUri = URI("file:///test/oldPath") val newUri = URI("file:///test/newPath") val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.txt") sut.after(listOf(moveEvent)) + testScope.advanceUntilIdle() verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } } @Test - fun `test didDeleteFiles with move event of directory`() = runTest { + fun `test didDeleteFiles with move event of directory`() { val oldUri = URI("file:///test/oldDir") val newUri = URI("file:///test/newDir") val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "", true) sut.after(listOf(moveEvent)) + testScope.advanceUntilIdle() val deleteParamsSlot = slot() verify { mockWorkspaceService.didDeleteFiles(capture(deleteParamsSlot)) } assertThat(deleteParamsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(oldUri.toString())) } @Test - fun `test didChangeWatchedFiles with valid events`() = runTest { + fun `test didChangeWatchedFiles with valid events`() { // Arrange val createURI = URI("file:///test/pathOfCreation") val deleteURI = URI("file:///test/pathOfDeletion") @@ -372,6 +395,7 @@ class WorkspaceServiceHandlerTest { sut.after(listOf(virtualFileCreate, virtualFileDelete, virtualFileChange)) // Assert + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.changes[0].uri).isEqualTo(normalizeFileUri(createURI.toString())) @@ -383,13 +407,14 @@ class WorkspaceServiceHandlerTest { } @Test - fun `test didChangeWatchedFiles with move event reports both delete and create`() = runTest { + fun `test didChangeWatchedFiles with move event reports both delete and create`() { val oldUri = URI("file:///test/oldPath") val newUri = URI("file:///test/newPath") val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.py") sut.after(listOf(moveEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } @@ -401,13 +426,14 @@ class WorkspaceServiceHandlerTest { } @Test - fun `test didChangeWatchedFiles with copy event`() = runTest { + fun `test didChangeWatchedFiles with copy event`() { val originalUri = URI("file:///test/original") val newUri = URI("file:///test/new") val copyEvent = createMockVFileCopyEvent(originalUri, newUri, "test.py") sut.after(listOf(copyEvent)) + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.changes[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) @@ -415,7 +441,7 @@ class WorkspaceServiceHandlerTest { } @Test - fun `test no invoked messages when events are empty`() = runTest { + fun `test no invoked messages when events are empty`() { // Act sut.after(emptyList()) @@ -426,7 +452,7 @@ class WorkspaceServiceHandlerTest { } @Test - fun `test didRenameFiles with supported file`() = runTest { + fun `test didRenameFiles with supported file`() { // Arrange val oldName = "oldFile.java" val newName = "newFile.java" @@ -441,6 +467,7 @@ class WorkspaceServiceHandlerTest { // Act sut.after(listOf(propertyEvent)) + testScope.advanceUntilIdle() val closeParams = slot() verify { mockTextDocumentService.didClose(capture(closeParams)) } assertThat(closeParams.captured.textDocument.uri).isEqualTo(normalizeFileUri("file:///testDir/$oldName")) @@ -464,7 +491,7 @@ class WorkspaceServiceHandlerTest { } @Test - fun `test didRenameFiles with unsupported file type`() = runTest { + fun `test didRenameFiles with unsupported file type`() { // Arrange val propertyEvent = createMockPropertyChangeEvent( oldName = "oldFile.txt", @@ -476,13 +503,14 @@ class WorkspaceServiceHandlerTest { sut.after(listOf(propertyEvent)) // Assert + testScope.advanceUntilIdle() verify(exactly = 0) { mockTextDocumentService.didClose(any()) } verify(exactly = 0) { mockTextDocumentService.didOpen(any()) } verify(exactly = 0) { mockWorkspaceService.didRenameFiles(any()) } } @Test - fun `test didRenameFiles with directory`() = runTest { + fun `test didRenameFiles with directory`() { // Arrange val propertyEvent = createMockPropertyChangeEvent( oldName = "oldDir", @@ -494,6 +522,7 @@ class WorkspaceServiceHandlerTest { sut.after(listOf(propertyEvent)) // Assert + testScope.advanceUntilIdle() verify(exactly = 0) { mockTextDocumentService.didClose(any()) } verify(exactly = 0) { mockTextDocumentService.didOpen(any()) } val paramsSlot = slot() @@ -505,7 +534,7 @@ class WorkspaceServiceHandlerTest { } @Test - fun `test didRenameFiles with multiple files`() = runTest { + fun `test didRenameFiles with multiple files`() { // Arrange val event1 = createMockPropertyChangeEvent( oldName = "old1.java", @@ -524,6 +553,7 @@ class WorkspaceServiceHandlerTest { sut.after(listOf(event1, event2)) // Assert + testScope.advanceUntilIdle() val paramsSlot = slot() verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } assertThat(paramsSlot.captured.files).hasSize(2) @@ -541,7 +571,7 @@ class WorkspaceServiceHandlerTest { } @Test - fun `rootsChanged does not notify when no changes`() = runTest { + fun `rootsChanged does not notify when no changes`() { // Arrange mockkObject(WorkspaceFolderUtil) val folders = listOf( @@ -557,12 +587,13 @@ class WorkspaceServiceHandlerTest { sut.rootsChanged(mockk()) // Assert + testScope.advanceUntilIdle() verify(exactly = 0) { mockWorkspaceService.didChangeWorkspaceFolders(any()) } } // rootsChanged handles @Test - fun `rootsChanged handles init`() = runTest { + fun `rootsChanged handles init`() { // Arrange mockkObject(WorkspaceFolderUtil) val oldFolders = emptyList() @@ -580,6 +611,7 @@ class WorkspaceServiceHandlerTest { sut.rootsChanged(mockk()) // Assert + testScope.advanceUntilIdle() val paramsSlot = slot() verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } assertThat(paramsSlot.captured.event.added).hasSize(1) @@ -588,7 +620,7 @@ class WorkspaceServiceHandlerTest { // rootsChanged handles additional files added to root @Test - fun `rootsChanged handles additional files added to root`() = runTest { + fun `rootsChanged handles additional files added to root`() { // Arrange mockkObject(WorkspaceFolderUtil) val oldFolders = listOf( @@ -615,6 +647,7 @@ class WorkspaceServiceHandlerTest { sut.rootsChanged(mockk()) // Assert + testScope.advanceUntilIdle() val paramsSlot = slot() verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } assertThat(paramsSlot.captured.event.added).hasSize(1) @@ -623,7 +656,7 @@ class WorkspaceServiceHandlerTest { // rootsChanged handles removal of files from root @Test - fun `rootsChanged handles removal of files from root`() = runTest { + fun `rootsChanged handles removal of files from root`() { // Arrange mockkObject(WorkspaceFolderUtil) val oldFolders = listOf( @@ -650,6 +683,7 @@ class WorkspaceServiceHandlerTest { sut.rootsChanged(mockk()) // Assert + testScope.advanceUntilIdle() val paramsSlot = slot() verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } assertThat(paramsSlot.captured.event.removed).hasSize(1) @@ -657,7 +691,7 @@ class WorkspaceServiceHandlerTest { } @Test - fun `rootsChanged handles multiple simultaneous additions and removals`() = runTest { + fun `rootsChanged handles multiple simultaneous additions and removals`() { // Arrange mockkObject(WorkspaceFolderUtil) val oldFolders = listOf( @@ -688,6 +722,7 @@ class WorkspaceServiceHandlerTest { sut.rootsChanged(mockk()) // Assert + testScope.advanceUntilIdle() val paramsSlot = slot() verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } assertThat(paramsSlot.captured.event.added).hasSize(1) diff --git a/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt b/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt index 1fdc7d0c9dd..11437defb65 100644 --- a/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt +++ b/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt @@ -60,6 +60,7 @@ open class CodeInsightTestFixtureRule(protected val testDescription: LightProjec this.description = description // This timer is cancelled but it still continues running when the test is over since it cancels lazily. This is fine, so suppress the leak ThreadLeakTracker.longRunningThreadCreated(ApplicationManager.getApplication(), "Debugger Worker launch timer") + ThreadLeakTracker.longRunningThreadCreated(ApplicationManager.getApplication(), "Test worker") } override fun after() { diff --git a/settings.gradle.kts b/settings.gradle.kts index a9a23c00f8d..f644ad6eeff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,12 @@ dependencyResolutionManagement { repositories { codeArtifactMavenRepo() mavenCentral() + maven { + url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") + content { + includeGroupByRegex("org\\.mockito\\.kotlin") + } + } intellijPlatform { defaultRepositories()