diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt index 8432d3e20cc..30d3187286c 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt @@ -29,7 +29,7 @@ fun Project.jvmTarget(): Provider = withCurrentProfileName { fun Project.kotlinTarget(): Provider = withCurrentProfileName { when (it) { "2024.3" -> KotlinVersionEnum.KOTLIN_2_0 - "2025.1", "2025.2" -> KotlinVersionEnum.KOTLIN_2_1 + "2025.1", "2025.2", "2025.3" -> KotlinVersionEnum.KOTLIN_2_1 else -> error("not set") }.version } diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt index 0fd34e946d6..4b4548f8eb5 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt @@ -152,6 +152,49 @@ object IdeVersions { rdGenVersion = "2025.2.2", nugetVersion = "2025.2.0" ) + ), + Profile( + name = "2025.3", + gateway = ProductProfile( + sdkVersion = "253.28086.53", + bundledPlugins = listOf("org.jetbrains.plugins.terminal") + ), + community = ProductProfile( + sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + bundledPlugins = commonPlugins + listOf( + "com.intellij.java", + "com.intellij.gradle", + "org.jetbrains.idea.maven", + "com.intellij.properties" + ), + marketplacePlugins = listOf( + "org.toml.lang:253.28294.86", + "PythonCore:253.28294.51", + "Docker:253.28294.90", + "com.intellij.modules.json:253.28294.51" + ) + ), + ultimate = ProductProfile( + sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + bundledPlugins = commonPlugins + listOf( + "JavaScript", + "JavaScriptDebugger", + "com.intellij.database", + "com.jetbrains.codeWithMe" + ), + marketplacePlugins = listOf( + "Pythonid:253.28294.51", + "org.jetbrains.plugins.go:253.28294.51", + "com.intellij.modules.json:253.28294.51" + ) + ), + rider = RiderProfile( + sdkVersion = "2025.3-SNAPSHOT", + bundledPlugins = commonPlugins, + netFrameworkTarget = "net472", + rdGenVersion = "2025.3.1", + nugetVersion = "2025.3.0" + ) ) ).associateBy { it.name } diff --git a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts index c2273e58c45..a832242651d 100644 --- a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts @@ -101,6 +101,14 @@ dependencies { bundledPlugins(toolkitIntelliJ.productProfile().map { it.bundledPlugins }) plugins(toolkitIntelliJ.productProfile().map { it.marketplacePlugins }) + // OAuth modules split in 2025.3 (253) - must be explicitly bundled + val versionStr = version.get() + if (versionStr.contains("253")) { + bundledModule("intellij.platform.collaborationTools") + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } + testFramework(TestFrameworkType.Plugin.Java) testFramework(TestFrameworkType.Platform) testFramework(TestFrameworkType.JUnit5) diff --git a/kotlinResolution.settings.gradle.kts b/kotlinResolution.settings.gradle.kts index ccc4ee9d06f..3023d2002d2 100644 --- a/kotlinResolution.settings.gradle.kts +++ b/kotlinResolution.settings.gradle.kts @@ -10,7 +10,7 @@ dependencyResolutionManagement { "1.8.0-intellij-11" } - "2025.2" -> { + "2025.2", "2025.3" -> { "1.10.1-intellij-5" } 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 19d1dbaba0b..816cf9cd657 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 @@ -17,6 +17,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.jcef.JBCefJSQuery.Response import kotlinx.coroutines.CancellationException @@ -103,7 +104,6 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SendC import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.StopResponseMessage import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.TELEMETRY_EVENT import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil -import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.toUriString import software.aws.toolkits.jetbrains.services.amazonq.util.command import software.aws.toolkits.jetbrains.services.amazonq.util.tabType import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.AmazonQTheme @@ -232,7 +232,11 @@ class BrowserConnector( SEND_CHAT_COMMAND_PROMPT -> { val requestFromUi = serializer.deserializeChatMessages(node) val editor = FileEditorManager.getInstance(project).selectedTextEditor - val textDocumentIdentifier = editor?.let { TextDocumentIdentifier(toUriString(it.virtualFile)) } + val textDocumentIdentifier = editor?.virtualFile?.let { virtualFile -> + val relativePath = VfsUtilCore.getRelativePath(virtualFile, project.baseDir) + ?: virtualFile.path + TextDocumentIdentifier(relativePath) + } val cursorState = editor?.let { LspEditorUtil.getCursorState(it) } val enrichmentParams = mapOf( @@ -362,7 +366,11 @@ class BrowserConnector( CHAT_INSERT_TO_CURSOR -> { val editor = FileEditorManager.getInstance(project).selectedTextEditor - val textDocumentIdentifier = editor?.let { TextDocumentIdentifier(toUriString(it.virtualFile)) } + val textDocumentIdentifier = editor?.virtualFile?.let { virtualFile -> + val relativePath = VfsUtilCore.getRelativePath(virtualFile, project.baseDir) + ?: virtualFile.path + TextDocumentIdentifier(relativePath) + } val cursorPosition = editor?.let { LspEditorUtil.getCursorPosition(it) } val enrichmentParams = mapOf( diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt index 726edc0212d..27416587264 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/file/util/LanguageExtractor.kt @@ -10,6 +10,6 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.language.programmi class LanguageExtractor { fun extractLanguageNameFromCurrentFile(editor: Editor): String = runReadAction { - editor.virtualFile.programmingLanguage().languageId + editor.virtualFile?.programmingLanguage()?.languageId ?: "plaintext" } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt index 210c0263c31..a6fa45db1e9 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/editor/context/focusArea/FocusAreaContextExtractor.kt @@ -114,7 +114,7 @@ class FocusAreaContextExtractor(private val fqnWebviewAdapter: FqnWebviewAdapter languageExtractor.extractLanguageNameFromCurrentFile(editor) } val fileText = editor.document.text - val fileName = editor.virtualFile.name + val fileName = editor.virtualFile?.name ?: "unknown" // Offset the selection range to the start of the trimmedFileText val selectionInsideTrimmedFileTextRange = codeSelectionRange.let { diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt b/plugins/amazonq/chat/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt similarity index 100% rename from plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt rename to plugins/amazonq/chat/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt diff --git a/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt b/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt new file mode 100644 index 00000000000..76a00df6560 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/TelemetryHelperTest.kt @@ -0,0 +1,639 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.intellij.testFramework.fixtures.impl.LightTempDirTestFixtureImpl +import com.intellij.testFramework.registerServiceInstance +import com.intellij.testFramework.replaceService +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import software.amazon.awssdk.awscore.DefaultAwsResponseMetadata +import software.amazon.awssdk.awscore.util.AwsHeader.AWS_REQUEST_ID +import software.amazon.awssdk.http.SdkHttpResponse +import software.amazon.awssdk.services.codewhispererruntime.model.ChatInteractWithMessageEvent +import software.amazon.awssdk.services.codewhispererruntime.model.ChatMessageInteractionType +import software.amazon.awssdk.services.codewhispererruntime.model.SendTelemetryEventResponse +import software.amazon.awssdk.services.codewhispererstreaming.model.UserIntent +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.aws.toolkits.core.telemetry.MetricEvent +import software.aws.toolkits.core.telemetry.TelemetryBatcher +import software.aws.toolkits.jetbrains.core.MockClientManagerExtension +import software.aws.toolkits.jetbrains.core.credentials.LegacyManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES +import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext +import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererCustomization +import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.ChatSession +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.ChatRequestData +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.CodeNamesImpl +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FullyQualifiedName +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.FullyQualifiedNames +import software.aws.toolkits.jetbrains.services.cwc.clients.chat.model.TriggerType +import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.TelemetryHelper +import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContext +import software.aws.toolkits.jetbrains.services.cwc.editor.context.file.FileContext +import software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea.FocusAreaContext +import software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea.UICodeSelectionLineRange +import software.aws.toolkits.jetbrains.services.cwc.editor.context.focusArea.UICodeSelectionRange +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.ChatMessageType +import software.aws.toolkits.jetbrains.services.cwc.messages.IncomingCwcMessage +import software.aws.toolkits.jetbrains.services.cwc.messages.LinkType +import software.aws.toolkits.jetbrains.services.cwc.storage.ChatSessionInfo +import software.aws.toolkits.jetbrains.services.cwc.storage.ChatSessionStorage +import software.aws.toolkits.jetbrains.services.telemetry.MockTelemetryServiceExtension +import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings +import software.aws.toolkits.telemetry.CwsprChatConversationType +import software.aws.toolkits.telemetry.CwsprChatInteractionType +import software.aws.toolkits.telemetry.CwsprChatTriggerInteraction +import software.aws.toolkits.telemetry.CwsprChatUserIntent +import kotlin.test.assertNotNull + +class TelemetryHelperTest { + private lateinit var myFixture: CodeInsightTestFixture + + // sut + private lateinit var sut: TelemetryHelper + + private lateinit var appInitContext: AmazonQAppInitContext + private lateinit var sessionStorage: ChatSessionStorage + + // dependencies + private lateinit var mockBatcher: TelemetryBatcher + private lateinit var mockClient: CodeWhispererClientAdaptor + private lateinit var mockConnectionManager: ToolkitConnectionManager + private lateinit var mockModelConfigurator: CodeWhispererModelConfigurator + + private lateinit var mockConnection: ToolkitConnection + + // Manual initialization instead of extensions to control timing + private val mockClientManager = MockClientManagerExtension() + private val mockTelemetryService = MockTelemetryServiceExtension() + + companion object { + private const val mockUrl = "mockUrl" + private const val mockRegion = "us-east-1" + private const val tabId = "tabId" + private const val messageId = "messageId" + private val userIntent = UserIntent.SHOW_EXAMPLES + private const val conversationId = "conversationId" + private const val triggerId = "triggerId" + private const val customizationArn = "customizationArn" + private const val steRequestId = "sendTelemetryEventRequestId" + private const val lang = "java" + private val mockCustomization = CodeWhispererCustomization(customizationArn, "name", "description") + + @JvmStatic + @BeforeAll + fun allowWindowsPythonPaths() { + if (SystemInfo.isWindows) { + VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "C:/Program Files") + } + } + private val data = ChatRequestData( + tabId = tabId, + message = "foo", + activeFileContext = ActiveFileContext( + FileContext(lang, "~/foo/bar/baz", null), + FocusAreaContext( + codeSelection = "", + codeSelectionRange = UICodeSelectionRange( + UICodeSelectionLineRange(1, 2), + UICodeSelectionLineRange(3, 4) + ), + trimmedSurroundingFileText = "", + codeNames = CodeNamesImpl( + listOf("simpleName_1"), + FullyQualifiedNames( + listOf( + FullyQualifiedName( + listOf("source_1"), + listOf("symbol_1") + ) + ) + ) + ) + ) + ), + userIntent = UserIntent.IMPROVE_CODE, + triggerType = TriggerType.Hotkeys, + customization = mockCustomization, + relevantTextDocuments = emptyList(), + useRelevantDocuments = true, + ) + private val response = ChatMessage( + tabId = tabId, + triggerId = triggerId, + messageType = ChatMessageType.Prompt, + messageId = messageId, + followUps = listOf(mock(), mock()) + ) + private val mockSteResponse = SendTelemetryEventResponse.builder() + .apply { + this.sdkHttpResponse( + SdkHttpResponse.builder().build() + ) + this.responseMetadata( + DefaultAwsResponseMetadata.create( + mapOf(AWS_REQUEST_ID to steRequestId) + ) + ) + }.build() + } + + @BeforeEach + fun setUp() { + // Create lightweight test fixture FIRST - this initializes Application + val factory = IdeaTestFixtureFactory.getFixtureFactory() + val fixtureBuilder = factory.createLightFixtureBuilder("TelemetryHelperTest") + myFixture = factory.createCodeInsightFixture(fixtureBuilder.fixture, LightTempDirTestFixtureImpl(true)) + myFixture.setUp() + + // NOW manually initialize mocks - Application exists now + mockClientManager.beforeEach(null) + mockTelemetryService.beforeEach(null) + + // Enable telemetry for tests + software.aws.toolkits.jetbrains.settings.AwsSettings.getInstance().isTelemetryEnabled = true + + // set up sut + appInitContext = AmazonQAppInitContext( + project = myFixture.project, + messagesFromAppToUi = mock(), + messagesFromUiToApp = mock(), + messageTypeRegistry = mock(), + fqnWebviewAdapter = mock() + ) + val mockSession = mock { + on { this.conversationId } doReturn conversationId + } + sessionStorage = mock { + on { this.getSession(eq(tabId)) } doReturn ChatSessionInfo(session = mockSession, scope = mock(), history = mutableListOf()) + } + sut = TelemetryHelper(appInitContext.project, sessionStorage) + + // set up client + mockClientManager.create() + + // set up connection + mockConnection = LegacyManagedBearerSsoConnection( + mockUrl, + mockRegion, + Q_SCOPES, + mock() + ) + mockConnectionManager = mock { + on { activeConnectionForFeature(eq(QConnection.getInstance())) } doReturn mockConnection + } + myFixture.project.replaceService(ToolkitConnectionManager::class.java, mockConnectionManager, myFixture.testRootDisposable) + + // set up telemetry service + mockBatcher = mockTelemetryService.batcher() + + // set up client + mockClient = mock() + myFixture.project.registerServiceInstance(CodeWhispererClientAdaptor::class.java, mockClient) + + // set up customization + mockModelConfigurator = mock { + on { activeCustomization(myFixture.project) } doReturn mockCustomization + } + ApplicationManager.getApplication().registerServiceInstance(CodeWhispererModelConfigurator::class.java, mockModelConfigurator) + } + + @AfterEach + fun tearDown() { + // Clean up mocks first + mockTelemetryService.afterEach(null) + mockClientManager.afterEach(null) + + // Then tear down fixture + myFixture.tearDown() + } + + @Test + fun testRecordAddMessage() { + mockClient.stub { + on { + sendChatAddMessageTelemetry(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } doReturn mockSteResponse + } + + // set up request data + val responseLength = 10 + val statusCode = 400 + val numberOfCodeBlocks = 1 + + sut.recordAddMessage( + data = data, + response = response, + responseLength = responseLength, + statusCode = statusCode, + numberOfCodeBlocks = numberOfCodeBlocks + ) + + // Q STE + verify(mockClient).sendChatAddMessageTelemetry( + sessionId = eq(conversationId), + requestId = eq(messageId), + userIntent = eq(software.amazon.awssdk.services.codewhispererruntime.model.UserIntent.fromValue(data.userIntent?.name)), + hasCodeSnippet = any(), + programmingLanguage = eq(lang), + activeEditorTotalCharacters = eq(data.activeFileContext.focusAreaContext?.codeSelection?.length), + timeToFirstChunkMilliseconds = eq(sut.getResponseStreamTimeToFirstChunk(tabId)), + timeBetweenChunks = eq(sut.getResponseStreamTimeBetweenChunks(tabId)), + fullResponselatency = any(), // TODO + requestLength = eq(data.message.length), + responseLength = eq(responseLength), + numberOfCodeBlocks = eq(numberOfCodeBlocks), + hasProjectLevelContext = eq(CodeWhispererSettings.getInstance().isProjectContextEnabled()), + customization = eq(mockCustomization) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_addMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)) + .matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversation id doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == "messageId" }, "message id doesn't match") + .matches( + { it.metadata["cwsprChatTriggerInteraction"] == CwsprChatTriggerInteraction.ContextMenu.toString() }, + "trigger type doesn't match" + ) + .matches({ it.metadata["cwsprChatUserIntent"] == CwsprChatUserIntent.ImproveCode.toString() }, "user intent doesn't match") + .matches({ + it.metadata["cwsprChatHasCodeSnippet"] == ( + data.activeFileContext.focusAreaContext?.codeSelection?.isNotEmpty() + ?: false + ).toString() + }, "has code snippet doesn't match") + .matches({ it.metadata["cwsprChatProgrammingLanguage"] == "java" }, "language doesn't match") + .matches( + { it.metadata["cwsprChatActiveEditorTotalCharacters"] == data.activeFileContext.focusAreaContext?.codeSelection?.length?.toString() }, + "total characters doesn't match" + ) + .matches( + { + it.metadata["cwsprChatActiveEditorImportCount"] == + data.activeFileContext.focusAreaContext?.codeNames?.fullyQualifiedNames?.used?.size?.toString() + }, + "import count doesn't match" + ) + .matches( + { it.metadata["cwsprChatResponseCodeSnippetCount"] == numberOfCodeBlocks.toString() }, + "number of code blocks doesn't match" + ) + .matches({ it.metadata["cwsprChatResponseCode"] == statusCode.toString() }, "response code doesn't match") + .matches( + { it.metadata["cwsprChatSourceLinkCount"] == response.relatedSuggestions?.size?.toString() }, + "source link count doesn't match" + ) + .matches({ it.metadata["cwsprChatFollowUpCount"] == response.followUps?.size?.toString() }, "follow up count doesn't match") + .matches( + { it.metadata["cwsprChatTimeToFirstChunk"] == sut.getResponseStreamTimeToFirstChunk(response.tabId).toInt().toString() }, + "time to first chunk doesn't match" + ) + .matches({ + it.metadata["cwsprChatTimeBetweenChunks"] == "[${ + sut.getResponseStreamTimeBetweenChunks(response.tabId).joinToString(", ") + }]" + }, "time between chunks doesn't match") + .matches({ it.metadata["cwsprChatRequestLength"] == data.message.length.toString() }, "request length doesn't match") + .matches({ it.metadata["cwsprChatResponseLength"] == responseLength.toString() }, "response length doesn't match") + .matches( + { it.metadata["cwsprChatConversationType"] == CwsprChatConversationType.Chat.toString() }, + "conversation type doesn't match" + ) + .matches({ it.metadata["codewhispererCustomizationArn"] == "customizationArn" }, "user intent doesn't match") + .matches({ + it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() + }, "customization description doesn't match") +// .matches({ it.metadata["cwsprChatFullResponseLatency"] == "" }, "latency") TODO + } + } + + @Test + fun `test recordInteractWithMessage - ChatItemVoted`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + sut.recordInteractWithMessage(IncomingCwcMessage.ChatItemVoted(tabId, messageId, "upvote")) + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.UPVOTE) + customizationArn(customizationArn) + hasProjectLevelContext(false) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)) + .matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.Upvote.toString() }, + "interaction type doesn't match" + ) + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "startUrl doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - FollowupClicked`() { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + runBlocking { + sut.setResponseHasProjectContext(messageId, true) + sut.recordInteractWithMessage(IncomingCwcMessage.FollowupClicked(mock(), tabId, messageId, "command", "tabType")) + } + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.CLICK_FOLLOW_UP) + customizationArn(customizationArn) + hasProjectLevelContext(true) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)) + .matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.ClickFollowUp.toString() }, + "interaction type doesn't match" + ) + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "startUrl doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == "true" }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - CopyCodeToClipboard`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + val codeBlockIndex = 1 + val totalCodeBlocks = 10 + + sut.recordInteractWithMessage( + IncomingCwcMessage.CopyCodeToClipboard( + "command", + tabId, + messageId, + userIntent, + "println()", + "insertionTargetType", + "eventId", + codeBlockIndex, + totalCodeBlocks, + lang + ) + ) + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.COPY_SNIPPET) + interactionTarget("insertionTargetType") + acceptedCharacterCount("println()".length) + customizationArn(customizationArn) + hasProjectLevelContext(false) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)) + .matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.CopySnippet.toString() }, + "interaction type doesn't match" + ) + .matches({ it.metadata["cwsprChatAcceptedCharactersLength"] == "println()".length.toString() }, "acceptedCharLength doesn't match") + .matches({ it.metadata["cwsprChatInteractionTarget"] == "insertionTargetType" }, "insertionTargetType doesn't match") + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "startUrl doesn't match") + .matches({ it.metadata["cwsprChatCodeBlockIndex"] == codeBlockIndex.toString() }, "cwsprChatCodeBlockIndex doesn't match") + .matches({ it.metadata["cwsprChatTotalCodeBlocks"] == totalCodeBlocks.toString() }, "cwsprChatTotalCodeBlocks doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - InsertCodeAtCursorPosition`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + val codeBlockIndex = 1 + val totalCodeBlocks = 10 + val inserTionTargetType = "insertionTargetType" + val eventId = "eventId" + val code = "println()" + + sut.recordInteractWithMessage( + IncomingCwcMessage.InsertCodeAtCursorPosition( + tabId, + messageId, + userIntent, + code, + inserTionTargetType, + emptyList(), + eventId, + codeBlockIndex, + totalCodeBlocks, + lang + ) + ) + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.INSERT_AT_CURSOR) + interactionTarget(inserTionTargetType) + acceptedCharacterCount(code.length) + acceptedLineCount(code.lines().size) + customizationArn(customizationArn) + hasProjectLevelContext(false) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)).matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.InsertAtCursor.toString() }, + "interaction type doesn't match" + ) + .matches( + { it.metadata["cwsprChatAcceptedCharactersLength"] == code.length.toString() }, + "cwsprChatAcceptedCharactersLength doesn't match" + ) + .matches( + { it.metadata["cwsprChatAcceptedNumberOfLines"] == code.lines().size.toString() }, + "cwsprChatAcceptedNumberOfLines doesn't match" + ) + .matches({ it.metadata["cwsprChatInteractionTarget"] == inserTionTargetType }, "cwsprChatInteractionTarget doesn't match") + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "credentialStartUrl doesn't match") + .matches({ it.metadata["cwsprChatCodeBlockIndex"] == codeBlockIndex.toString() }, "cwsprChatCodeBlockIndex doesn't match") + .matches({ it.metadata["cwsprChatTotalCodeBlocks"] == totalCodeBlocks.toString() }, "cwsprChatTotalCodeBlocks doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - ClickedLink`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + val link = "https://foo.bar.com" + sut.recordInteractWithMessage( + IncomingCwcMessage.ClickedLink( + LinkType.SourceLink, + tabId, + messageId, + link + ) + ) + + // STE + verify(mockClient).sendChatInteractWithMessageTelemetry( + eq( + ChatInteractWithMessageEvent.builder().apply { + conversationId(conversationId) + messageId(messageId) + interactionType(ChatMessageInteractionType.CLICK_LINK) + interactionTarget(link) + customizationArn(customizationArn) + hasProjectLevelContext(false) + }.build() + ) + ) + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher).enqueue(capture()) + val event = firstValue.data.find { it.name == "amazonq_interactWithMessage" } + assertNotNull(event) + assertThat(requireNotNull(event)).matches({ it.metadata["cwsprChatConversationId"] == conversationId }, "conversationId doesn't match") + .matches({ it.metadata["cwsprChatMessageId"] == messageId }, "messageId doesn't match") + .matches( + { it.metadata["cwsprChatInteractionType"] == CwsprChatInteractionType.ClickLink.toString() }, + "interaction type doesn't match" + ) + .matches({ it.metadata["cwsprChatInteractionTarget"] == link }, "cwsprChatInteractionTarget doesn't match") + .matches({ it.metadata["credentialStartUrl"] == mockUrl }, "credentialStartUrl doesn't match") + .matches( + { it.metadata["cwsprChatHasProjectContext"] == CodeWhispererSettings.getInstance().isProjectContextEnabled().toString() }, + "hasProjectContext doesn't match" + ) + } + } + + @Test + fun `test recordInteractWithMessage - ChatItemFeedback`() = runTest { + mockClient.stub { + on { this.sendChatInteractWithMessageTelemetry(any()) } doReturn mockSteResponse + } + + val selectedOption = "foo" + val comment = "bar" + + sut.recordInteractWithMessage( + IncomingCwcMessage.ChatItemFeedback( + tabId, + selectedOption, + comment, + messageId, + ) + ) + + // TODO: STE, not implemented yet + + // Toolkit telemetry + argumentCaptor { + verify(mockBatcher, times(2)).enqueue(capture()) + val event = firstValue.data.find { it.name == "feedback_result" } + assertNotNull(event) + assertThat(requireNotNull(event)).matches { it.metadata["result"] == "Succeeded" } + } + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt index 20e21f46ba5..fd93cc89c12 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt @@ -3,12 +3,16 @@ package software.aws.toolkits.jetbrains.services.amazonq.clients +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess import com.intellij.testFramework.RuleChain import com.intellij.testFramework.replaceService import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Before +import org.junit.BeforeClass import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any @@ -54,6 +58,12 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { @Before override fun setup() { super.setup() + + // Allow Python paths on Windows for test environment (Python plugin scans for interpreters) + if (SystemInfo.isWindows) { + VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") + } + amazonQStreamingClient = AmazonQStreamingClient.getInstance(projectRule.project) ssoClient = mockClientManagerRule.create() @@ -233,6 +243,14 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { } companion object { + @JvmStatic + @BeforeClass + fun allowWindowsPythonPaths() { + if (SystemInfo.isWindows) { + VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "C:/Program Files") + } + } + private val VALIDATION_EXCEPTION = ValidationException.builder() .message("Resource validation failed") .build() diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts b/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts index 015c6746975..824d0f016ff 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { compileOnly(project(":plugin-core:jetbrains-community")) implementation(project(":plugin-amazonq:shared:jetbrains-community")) + implementation(libs.lsp4j) // CodeWhispererTelemetryService uses a CircularFifoQueue, previously transitive from zjsonpatch implementation(libs.commons.collections) 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 d4d264e4d13..b3398e170e8 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 @@ -512,8 +512,12 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { editor: Editor, triggerTypeInfo: TriggerTypeInfo, nextToken: Either?, - ): InlineCompletionWithReferencesParams = - ReadAction.compute { + ): InlineCompletionWithReferencesParams { + // Resolve and validate the virtualFile before entering the ReadAction + val virtualFile = editor.virtualFile + ?: error("Editor virtualFile is null for CodeWhisperer inline completion") + + return ReadAction.compute { InlineCompletionWithReferencesParams( context = InlineCompletionContext( // Map the triggerTypeInfo to appropriate InlineCompletionTriggerKind @@ -542,7 +546,7 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { .openFiles.mapNotNull { toUriString(it) } }.orEmpty(), ).apply { - textDocument = TextDocumentIdentifier(toUriString(editor.virtualFile)) + textDocument = TextDocumentIdentifier(toUriString(virtualFile)) position = Position( editor.caretModel.primaryCaret.logicalPosition.line, editor.caretModel.primaryCaret.logicalPosition.column @@ -552,6 +556,7 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { } } } + } private fun addPopupChildDisposables(popup: JBPopup) { codeInsightSettingsFacade.disableCodeInsightUntil(popup) 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 dadbeb2f8dc..f8604b89686 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 @@ -558,8 +558,12 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { editor: Editor, triggerTypeInfo: TriggerTypeInfo, nextToken: Either?, - ): InlineCompletionWithReferencesParams = - ReadAction.compute { + ): InlineCompletionWithReferencesParams { + // Resolve and validate the virtualFile before entering the ReadAction + val virtualFile = editor.virtualFile + ?: error("Editor virtualFile is null for CodeWhisperer inline completion (new)") + + return ReadAction.compute { InlineCompletionWithReferencesParams( context = InlineCompletionContext( // Map the triggerTypeInfo to appropriate InlineCompletionTriggerKind @@ -572,7 +576,7 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { documentChangeParams = null, openTabFilepaths = null, ).apply { - textDocument = TextDocumentIdentifier(toUriString(editor.virtualFile)) + textDocument = TextDocumentIdentifier(toUriString(virtualFile)) position = Position( editor.caretModel.primaryCaret.logicalPosition.line, editor.caretModel.primaryCaret.logicalPosition.column @@ -582,7 +586,7 @@ class CodeWhispererServiceNew(private val cs: CoroutineScope) : Disposable { } } } - + } private fun logServiceInvocation( requestContext: RequestContextNew, responseContext: ResponseContext, diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt similarity index 100% rename from plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt similarity index 100% rename from plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt rename to plugins/amazonq/codewhisperer/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt new file mode 100644 index 00000000000..1323774b3d7 --- /dev/null +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeInsightsSettingsFacadeTest.kt @@ -0,0 +1,121 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.codeInsight.CodeInsightSettings +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.replaceService +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +class CodeInsightsSettingsFacadeTest : HeavyPlatformTestCase() { + private lateinit var settings: CodeInsightSettings + private lateinit var sut: CodeInsightsSettingsFacade + + override fun setUp() { + super.setUp() + sut = spy(CodeInsightsSettingsFacade()) + settings = spy { CodeInsightSettings() } + + ApplicationManager.getApplication().replaceService( + CodeInsightSettings::class.java, + settings, + testRootDisposable + ) + } + + fun testDisableCodeInsightUntilShouldRevertWhenParentIsDisposed() { + @Suppress("ObjectLiteralToLambda") // JUnit 3 doesn't support SAM lambdas + val myFakePopup = object : Disposable { override fun dispose() {} } + Disposer.register(testRootDisposable, myFakePopup) + + // assume users' enable the following two codeinsight functionalities + settings.TAB_EXITS_BRACKETS_AND_QUOTES = true + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + settings.AUTOCOMPLETE_ON_CODE_COMPLETION = true + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + + // codewhisperer disable them while popup is shown + sut.disableCodeInsightUntil(myFakePopup) + + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isFalse + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isFalse + assertThat(sut.pendingRevertCounts).isEqualTo(2) + + // popup is closed and disposed + Disposer.dispose(myFakePopup) + + // revert changes made by codewhisperer + verify(sut, times(2)).revertAll() + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + } + + fun testRevertAllShouldRevertBackAllChangesMadeByCodewhisperer() { + settings.TAB_EXITS_BRACKETS_AND_QUOTES = true + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + settings.AUTOCOMPLETE_ON_CODE_COMPLETION = true + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + + sut.disableCodeInsightUntil(testRootDisposable) + + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isFalse + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isFalse + + assertThat(sut.pendingRevertCounts).isEqualTo(2) + + sut.revertAll() + assertThat(sut.pendingRevertCounts).isEqualTo(0) + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + } + + fun testDisableCodeInsightUntilShouldAlwaysFlushPendingRevertsBeforeMakingNextChanges() { + @Suppress("ObjectLiteralToLambda") // JUnit 3 doesn't support SAM lambdas + val myFakePopup = object : Disposable { override fun dispose() {} } + Disposer.register(testRootDisposable, myFakePopup) + + @Suppress("ObjectLiteralToLambda") // JUnit 3 doesn't support SAM lambdas + val myAnotherFakePopup = object : Disposable { override fun dispose() {} } + Disposer.register(testRootDisposable, myAnotherFakePopup) + + // assume users' enable the following two codeinsight functionalities + settings.TAB_EXITS_BRACKETS_AND_QUOTES = true + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + settings.AUTOCOMPLETE_ON_CODE_COMPLETION = true + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + + // codewhisperer disable them while popup_1 is shown + sut.disableCodeInsightUntil(myFakePopup) + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isFalse + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isFalse + assertThat(sut.pendingRevertCounts).isEqualTo(2) + verify(sut, times(1)).revertAll() + + // unexpected issue happens and popup_1 is not disposed correctly and popup_2 is created + sut.disableCodeInsightUntil(myAnotherFakePopup) + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isFalse + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isFalse + // should still be 2 because previous ones should be reverted before preceding next changes + assertThat(sut.pendingRevertCounts).isEqualTo(2) + verify(sut, times(1 + 1)).revertAll() + + Disposer.dispose(myAnotherFakePopup) + + assertThat(sut.pendingRevertCounts).isEqualTo(0) + verify(sut, times(1 + 1 + 1)).revertAll() + assertThat(settings.TAB_EXITS_BRACKETS_AND_QUOTES).isTrue + assertThat(settings.AUTO_POPUP_COMPLETION_LOOKUP).isTrue + } + + fun testDisposeShouldCallRevertAllToRevertAllChangesMadeByCodeWhisperer() { + sut.dispose() + verify(sut).revertAll() + } +} diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt new file mode 100644 index 00000000000..60c5da294d3 --- /dev/null +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/codewhisperer/util/CodeWhispererUtilTest.kt @@ -0,0 +1,55 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.codewhisperer.util + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.replaceService +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.credentials.ManagedBearerSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.ReauthSource +import software.aws.toolkits.jetbrains.core.credentials.ToolkitAuthManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.reauthConnectionIfNeeded +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderRule + +class CodeWhispererUtilTest : HeavyPlatformTestCase() { + private val mockRegionProviderExtension = MockRegionProviderRule() + + override fun setUp() { + super.setUp() + mockRegionProviderExtension.apply( + object : org.junit.runners.model.Statement() { + override fun evaluate() {} + }, + org.junit.runner.Description.EMPTY + ).evaluate() + } + + fun testReconnectCodeWhispererRespectsConnectionSettings() { + mockkStatic(::reauthConnectionIfNeeded) + val mockConnectionManager = mockk(relaxed = true) + val mockConnection = mockk() + project.replaceService(ToolkitConnectionManager::class.java, mockConnectionManager, testRootDisposable) + ApplicationManager.getApplication().replaceService(ToolkitAuthManager::class.java, mockk(relaxed = true), testRootDisposable) + val startUrl = aString() + val region = mockRegionProviderExtension.createAwsRegion().id + val scopes = listOf(aString(), aString()) + + every { mockConnectionManager.activeConnectionForFeature(any()) } returns mockConnection + every { mockConnection.startUrl } returns startUrl + every { mockConnection.region } returns region + every { mockConnection.scopes } returns scopes + + CodeWhispererUtil.reconnectCodeWhisperer(project) + + verify { + reauthConnectionIfNeeded(project, mockConnection, isReAuth = true, reauthSource = ReauthSource.CODEWHISPERER) + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts index 205c6806b86..36313073d9b 100644 --- a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // CodeWhispererTelemetryService uses a CircularFifoQueue implementation(libs.commons.collections) implementation(libs.nimbus.jose.jwt) + api(libs.lsp4j) testFixturesApi(testFixtures(project(":plugin-core:jetbrains-community"))) } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt b/plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt rename to plugins/amazonq/shared/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt 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 9a211e8b7f6..981b4152865 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 @@ -500,9 +500,11 @@ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageC // Send the active text file path with pinned context val editor = FileEditorManager.getInstance(project).selectedTextEditor val textDocument = editor?.let { - val relativePath = VfsUtilCore.getRelativePath(it.virtualFile, project.baseDir) - ?: it.virtualFile.path // Use absolute path if not in project - TextDocumentIdentifier(relativePath) + it.virtualFile?.let { virtualFile -> + val relativePath = VfsUtilCore.getRelativePath(virtualFile, project.baseDir) + ?: virtualFile.path // Use absolute path if not in project + TextDocumentIdentifier(relativePath) + } } // Create updated params with text document information 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 b49f412f987..2d6494f3dd6 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 @@ -196,10 +196,11 @@ class TextDocumentServiceHandler( private fun handleActiveEditorChange(fileEditor: FileEditor?) { val editor = (fileEditor as? TextEditor)?.editor ?: return - editor.virtualFile?.let { handleFileOpened(it) } + val virtualFile = editor.virtualFile ?: return // Return early if no file + handleFileOpened(virtualFile) // Extract text editor if it's a TextEditor, otherwise null - val textDocumentIdentifier = TextDocumentIdentifier(toUriString(editor.virtualFile)) + val textDocumentIdentifier = TextDocumentIdentifier(toUriString(virtualFile)) val cursorState = getCursorState(editor) val params = mapOf( diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt rename to plugins/amazonq/shared/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt 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-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt similarity index 100% rename from plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt rename to plugins/amazonq/shared/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt diff --git a/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt new file mode 100644 index 00000000000..5a88002ef5b --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt @@ -0,0 +1,130 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.extensions.PluginId +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.util.text.SemVer +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange +import java.nio.file.Files +import java.nio.file.Path + +class ArtifactManagerTest : HeavyPlatformTestCase() { + private lateinit var tempDir: Path + private lateinit var artifactHelper: ArtifactHelper + private lateinit var artifactManager: ArtifactManager + private lateinit var manifestFetcher: ManifestFetcher + private lateinit var manifestVersionRanges: SupportedManifestVersionRange + + override fun setUp() { + super.setUp() + tempDir = Files.createTempDirectory("artifact-test") + artifactHelper = spyk(ArtifactHelper(tempDir, 3)) + manifestFetcher = spyk(ManifestFetcher()) + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper)) + } + + fun testFetchArtifactFetcherReturnsBundledIfManifestIsNull() = runTest { + every { manifestFetcher.fetch() }.returns(null) + + assertThat(artifactManager.fetchArtifact(project)) + .isEqualTo( + PluginManagerCore.getPlugin(PluginId.getId("amazon.q"))?.pluginPath?.resolve("flare") + ) + } + + fun testFetchArtifactDoesNotHaveAnyValidLspVersionsReturnsBundled() = runTest { + every { manifestFetcher.fetch() }.returns(Manifest()) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = emptyList()) + ) + + assertThat(artifactManager.fetchArtifact(project)) + .isEqualTo( + PluginManagerCore.getPlugin(PluginId.getId("amazon.q"))?.pluginPath?.resolve("flare") + ) + } + + fun testGetLSPVersionsFromManifestWithSpecifiedRangeExcludesEndMajorVersion() = runTest { + val newManifest = Manifest(versions = listOf(Version(serverVersion = "2.0.0"))) + val result = artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(newManifest) + assertThat(result.inRangeVersions).isEmpty() + } + + fun testFetchArtifactIfInRangeVersionsAreNotAvailableShouldFallbackToLocalLsp() = runTest { + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + every { manifestFetcher.fetch() }.returns(Manifest()) + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + artifactManager.fetchArtifact(project) + + verify(exactly = 1) { manifestFetcher.fetch() } + verify(exactly = 1) { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) } + } + + fun testFetchArtifactHaveValidVersionInLocalSystem() = runTest { + val target = VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(Version("1.0.0", targets = listOf(target))) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(Manifest()) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { getCurrentOS() }.returns("temp") + every { getCurrentArchitecture() }.returns("temp") + + every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(false) + coEvery { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } returns tempDir + every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + + artifactManager.fetchArtifact(project) + + coVerify(exactly = 1) { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } + + fun testFetchArtifactDoesNotHaveValidVersionInLocalSystem() = runTest { + val target = VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(Version("1.0.0", targets = listOf(target))) + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(Manifest()) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { getCurrentOS() }.returns("temp") + every { getCurrentArchitecture() }.returns("temp") + + every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(true) + every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + artifactManager.fetchArtifact(project) + + coVerify(exactly = 0) { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt new file mode 100644 index 00000000000..b1946f9e556 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt @@ -0,0 +1,262 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.project.Project +import com.intellij.testFramework.HeavyPlatformTestCase +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 +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 software.aws.toolkits.core.TokenConnectionSettings +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 +import software.aws.toolkits.jetbrains.utils.isQConnected +import software.aws.toolkits.jetbrains.utils.isQExpired +import java.time.Instant +import java.util.concurrent.CompletableFuture + +class DefaultAuthCredentialsServiceTest : HeavyPlatformTestCase() { + companion object { + private const val TEST_ACCESS_TOKEN = "test-access-token" + } + + private lateinit var spyProject: Project + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockEncryptionManager: JwtEncryptionManager + private lateinit var mockConnectionManager: ToolkitConnectionManager + private lateinit var mockConnection: AwsBearerTokenConnection + private lateinit var sut: DefaultAuthCredentialsService + + override fun setUp() { + super.setUp() + spyProject = spyk(project) + setupMockLspService() + setupMockMessageBus() + setupMockConnectionManager() + } + + private fun setupMockLspService() { + mockLanguageServer = mockk() + mockEncryptionManager = mockk { + every { encrypt(any()) } returns "mock-encrypted-data" + } + + val mockLspService = mockk() + coEvery { + mockLspService.executeIfRunning>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + every { + mockLanguageServer.updateTokenCredentials(any()) + } returns CompletableFuture.completedFuture(ResponseMessage()) + + every { + mockLanguageServer.deleteTokenCredentials() + } returns Unit + + every { + mockLanguageServer.updateConfiguration(any()) + } returns CompletableFuture.completedFuture(LspServerConfigurations(emptyList())) + + every { spyProject.getService(AmazonQLspService::class.java) } returns mockLspService + every { spyProject.serviceIfCreated() } returns mockLspService + } + + private fun setupMockMessageBus() { + val messageBus = mockk() + val mockConnection = mockk { + every { subscribe(any(), any()) } just runs + } + every { spyProject.messageBus } returns messageBus + every { messageBus.connect(any()) } returns mockConnection + } + + private fun setupMockConnectionManager(accessToken: String = TEST_ACCESS_TOKEN) { + mockConnection = createMockConnection(accessToken) + mockConnectionManager = mockk { + every { activeConnectionForFeature(any()) } returns mockConnection + every { connectionStateForFeature(any()) } returns BearerTokenAuthState.AUTHORIZED + } + spyProject.replaceService(ToolkitConnectionManager::class.java, mockConnectionManager, spyProject) + mockkStatic("software.aws.toolkits.jetbrains.utils.FunctionUtilsKt") + // these set so init doesn't always emit + every { isQConnected(any()) } returns false + every { isQExpired(any()) } returns true + } + + private fun createMockConnection( + accessToken: String, + connectionId: String = "test-connection-id", + ): AwsBearerTokenConnection = mockk { + every { id } returns connectionId + every { startUrl } returns "startUrl" + every { getConnectionSettings() } returns createMockTokenSettings(accessToken) + } + + private fun createMockTokenSettings(accessToken: String): TokenConnectionSettings { + val token = PKCEAuthorizationGrantToken( + issuerUrl = "https://example.com", + refreshToken = "refreshToken", + accessToken = accessToken, + expiresAt = Instant.MAX, + createdAt = Instant.now(), + region = "us-fake-1", + ) + + val tokenDelegate = mockk { + every { currentToken() } returns token + } + + val provider = mockk { + every { delegate } returns tokenDelegate + } + + return mockk { + every { tokenProvider } returns provider + } + } + + fun testActiveConnectionChangedUpdatesTokenWhenConnectionIdMatchesQConnection() = runTest { + sut = DefaultAuthCredentialsService(spyProject, 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()) } + } + + fun testActiveConnectionChangedDoesNotUpdateTokenWhenConnectionIdDiffers() = runTest { + sut = DefaultAuthCredentialsService(spyProject, 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()) } + } + + fun testOnChangeUpdatesTokenWithNewConnection() = runTest { + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + setupMockConnectionManager("updated-token") + + sut.onProviderChange("providerId", listOf("new-scope")) + + advanceUntilIdle() + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } + + fun testInitDoesNotUpdateTokenWhenQIsNotConnected() = runTest { + every { isQConnected(spyProject) } returns false + every { isQExpired(spyProject) } returns false + + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + advanceUntilIdle() + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + fun testInitDoesNotUpdateTokenWhenQIsExpired() = runTest { + every { isQConnected(spyProject) } returns true + every { isQExpired(spyProject) } returns true + + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + advanceUntilIdle() + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + fun testUpdateTokenCredentialsUnencryptedSuccess() = runTest { + val isEncrypted = false + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + sut.updateTokenCredentials(mockConnection, isEncrypted) + + advanceUntilIdle() + verify(exactly = 1) { + mockLanguageServer.updateTokenCredentials( + UpdateCredentialsPayload( + "test-access-token", + ConnectionMetadata( + SsoProfileData("startUrl") + ), + isEncrypted + ) + ) + } + } + + fun testUpdateTokenCredentialsEncryptedSuccess() = runTest { + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + val encryptedToken = "encryptedToken" + val isEncrypted = true + + every { mockEncryptionManager.encrypt(any()) } returns encryptedToken + + sut.updateTokenCredentials(mockConnection, isEncrypted) + + advanceUntilIdle() + verify(atLeast = 1) { + mockLanguageServer.updateTokenCredentials( + UpdateCredentialsPayload( + encryptedToken, + ConnectionMetadata( + SsoProfileData("startUrl") + ), + isEncrypted + ) + ) + } + } + + fun testDeleteTokenCredentialsSuccess() = runTest { + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + every { mockLanguageServer.deleteTokenCredentials() } returns Unit + + sut.deleteTokenCredentials() + + advanceUntilIdle() + verify(exactly = 1) { mockLanguageServer.deleteTokenCredentials() } + } + + fun testInitResultsInTokenUpdate() = runTest { + every { isQConnected(any()) } returns true + every { isQExpired(any()) } returns false + sut = DefaultAuthCredentialsService(spyProject, mockEncryptionManager, this) + + advanceUntilIdle() + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } +} diff --git a/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts b/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts index be7eae7ba3a..ee3df06cae0 100644 --- a/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts +++ b/plugins/amazonq/shared/jetbrains-ultimate/build.gradle.kts @@ -12,6 +12,14 @@ intellijToolkit { } dependencies { + intellijPlatform { + // RD platform is only available in 2025.3 and later + when (providers.gradleProperty("ideProfileName").get()) { + "2025.2", "2025.3" -> { + bundledModule("intellij.rd.platform") + } + } + } compileOnly(project(":plugin-amazonq:shared:jetbrains-community")) compileOnly(project(":plugin-core:jetbrains-ultimate")) diff --git a/plugins/amazonq/shared/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt b/plugins/amazonq/shared/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt new file mode 100644 index 00000000000..3346352e7ed --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/CwmProblemsViewMutator.kt @@ -0,0 +1,19 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains + +import com.intellij.analysis.problemsView.toolWindow.ProblemsView +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.jetbrains.rdserver.toolWindow.BackendToolWindowHost + +class CwmProblemsViewMutator : ProblemsViewMutator { + override fun mutateProblemsView(project: Project, runnable: (ToolWindow) -> Unit) { + BackendToolWindowHost.getAllInstances(project).forEach { host -> + host.getToolWindow(ProblemsView.ID)?.let { + runnable(it) + } + } + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt b/plugins/core/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt similarity index 100% rename from plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt rename to plugins/core/jetbrains-community/src-242-252/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt diff --git a/plugins/core/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt b/plugins/core/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt new file mode 100644 index 00000000000..698e6e43b95 --- /dev/null +++ b/plugins/core/jetbrains-community/src-253+/software/aws/toolkits/jetbrains/services/telemetry/otel/OTelService.kt @@ -0,0 +1,171 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +@file:Suppress("UnusedPrivateClass") + +package software.aws.toolkits.jetbrains.services.telemetry.otel + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.util.SystemInfoRt +import com.intellij.platform.util.http.ContentType +import com.intellij.platform.util.http.httpPost +import com.intellij.serviceContainer.NonInjectable +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator +import io.opentelemetry.context.Context +import io.opentelemetry.context.propagation.ContextPropagators +import io.opentelemetry.exporter.internal.otlp.traces.TraceRequestMarshaler +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.opentelemetry.sdk.trace.ReadableSpan +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.SpanProcessor +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider +import software.amazon.awssdk.http.ContentStreamProvider +import software.amazon.awssdk.http.HttpExecuteRequest +import software.amazon.awssdk.http.SdkHttpMethod +import software.amazon.awssdk.http.SdkHttpRequest +import software.amazon.awssdk.http.apache.ApacheHttpClient +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner +import java.io.ByteArrayOutputStream +import java.net.ConnectException + +private class BasicOtlpSpanProcessor( + private val coroutineScope: CoroutineScope, + private val traceUrl: String = "http://127.0.0.1:4318/v1/traces", +) : SpanProcessor { + override fun onStart(parentContext: Context, span: ReadWriteSpan) {} + override fun isStartRequired() = false + override fun isEndRequired() = true + + override fun onEnd(span: ReadableSpan) { + val data = span.toSpanData() + coroutineScope.launch { + try { + val item = TraceRequestMarshaler.create(listOf(data)) + val output = ByteArrayOutputStream() + item.writeBinaryTo(output) + + httpPost(traceUrl, contentType = ContentType.XProtobuf, body = output.toByteArray()) + } catch (e: CancellationException) { + throw e + } catch (e: ConnectException) { + thisLogger().warn("Cannot export (url=$traceUrl): ${e.message}") + } catch (e: Throwable) { + thisLogger().error("Cannot export (url=$traceUrl)", e) + } + } + } +} + +private class SigV4OtlpSpanProcessor( + private val coroutineScope: CoroutineScope, + private val traceUrl: String, + private val creds: AwsCredentialsProvider, +) : SpanProcessor { + override fun onStart(parentContext: Context, span: ReadWriteSpan) {} + override fun isStartRequired() = false + override fun isEndRequired() = true + + private val client = ApacheHttpClient.create() + + override fun onEnd(span: ReadableSpan) { + coroutineScope.launch { + val data = span.toSpanData() + try { + val item = TraceRequestMarshaler.create(listOf(data)) + // calculate the sigv4 header + val signer = AwsV4HttpSigner.create() + val httpRequest = + SdkHttpRequest.builder() + .uri(traceUrl) + .method(SdkHttpMethod.POST) + .putHeader("Content-Type", "application/x-protobuf") + .build() + + val baos = ByteArrayOutputStream() + item.writeBinaryTo(baos) + val payload = ContentStreamProvider.fromByteArray(baos.toByteArray()) + val signedRequest = signer.sign { + it.identity(creds.resolveIdentity().get()) + it.request(httpRequest) + it.payload(payload) + it.putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, "osis") + it.putProperty(AwsV4HttpSigner.REGION_NAME, "us-west-2") + } + + // Create and HTTP client and send the request. ApacheHttpClient requires the 'apache-client' module. + client.prepareRequest( + HttpExecuteRequest.builder() + .request(signedRequest.request()) + .contentStreamProvider(signedRequest.payload().orElse(null)) + .build() + ).call() + } catch (e: CancellationException) { + throw e + } catch (e: ConnectException) { + thisLogger().warn("Cannot export (url=$traceUrl): ${e.message}") + } catch (e: Throwable) { + thisLogger().error("Cannot export (url=$traceUrl)", e) + } + } + } +} + +private object StdoutSpanProcessor : SpanProcessor { + override fun onStart(parentContext: Context, span: ReadWriteSpan) {} + override fun isStartRequired() = false + override fun isEndRequired() = true + + override fun onEnd(span: ReadableSpan) { + println(span.toSpanData()) + } +} + +@Service +class OTelService @NonInjectable internal constructor(spanProcessors: List) : Disposable { + @Suppress("unused") + constructor() : this(listOf(ToolkitTelemetryOTelSpanProcessor())) + + private val sdkDelegate = lazy { + OpenTelemetrySdk.builder() + .setTracerProvider( + SdkTracerProvider.builder() + .apply { + spanProcessors.forEach { + addSpanProcessor(it) + } + } + .setResource( + Resource.create( + Attributes.builder() + .put(AttributeKey.stringKey("os.type"), SystemInfoRt.OS_NAME) + .put(AttributeKey.stringKey("os.version"), SystemInfoRt.OS_VERSION) + .put(AttributeKey.stringKey("host.arch"), System.getProperty("os.arch")) + .build() + ) + ) + .build() + ) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build() + } + internal val sdk: OpenTelemetrySdk by sdkDelegate + + override fun dispose() { + if (sdkDelegate.isInitialized()) { + sdk.close() + } + } + + companion object { + fun getSdk() = service().sdk + } +} diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt b/plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt similarity index 100% rename from plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt rename to plugins/core/jetbrains-community/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt new file mode 100644 index 00000000000..9b04da18ccd --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt @@ -0,0 +1,332 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.project.Project +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.assertj.core.api.ObjectAssert +import org.junit.jupiter.api.assertDoesNotThrow +import org.mockito.kotlin.mock +import software.aws.toolkits.jetbrains.core.webview.BrowserMessage +import software.aws.toolkits.jetbrains.core.webview.BrowserState +import software.aws.toolkits.jetbrains.core.webview.LoginBrowser + +class NoOpLoginBrowser(project: Project) : LoginBrowser(project) { + override val jcefBrowser: JBCefBrowserBase = mock() + + override fun prepareBrowser(state: BrowserState) {} + + override fun loadWebView(query: JBCefJSQuery) {} + + override fun handleBrowserMessage(message: BrowserMessage?) {} +} + +class BrowserMessageTest : HeavyPlatformTestCase() { + private lateinit var objectMapper: ObjectMapper + + private inline fun assertDeserializedInstanceOf(jsonStr: String): ObjectAssert { + val actual = objectMapper.readValue(jsonStr) + return assertThat(actual).isInstanceOf(T::class.java) + } + + private inline fun assertDeserializedWillThrow(jsonStr: String) { + assertThatThrownBy { + objectMapper.readValue(jsonStr) + }.isInstanceOf(T::class.java) + } + + override fun setUp() { + super.setUp() + objectMapper = NoOpLoginBrowser(project).objectMapper + } + + fun `test exact match, deserialization return correct BrowserMessage subtype`() { + assertDeserializedInstanceOf( + """ + { + "command": "prepareUi" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "toggleBrowser" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "selectConnection", + "connectionId": "foo" + } + """ + ).isEqualTo(BrowserMessage.SelectConnection("foo")) + + assertDeserializedInstanceOf( + """ + { + "command": "loginBuilderId" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "loginIdC", + "url": "foo", + "region": "bar", + "feature": "baz" + } + """ + ).isEqualTo( + BrowserMessage.LoginIdC( + url = "foo", + region = "bar", + feature = "baz" + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "loginIAM", + "profileName": "foo", + "accessKey": "bar", + "secretKey": "baz" + } + """ + ).isEqualTo( + BrowserMessage.LoginIAM( + profileName = "foo", + accessKey = "bar", + secretKey = "baz" + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "cancelLogin" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "signout" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "reauth" + } + """ + ) + + assertDeserializedInstanceOf( + """ + { + "command": "sendUiClickTelemetry" + } + """ + ).isEqualTo( + BrowserMessage.SendUiClickTelemetry( + signInOptionClicked = null + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "webviewTelemetry", + "event": "{ \"metricName\": \"foo\" }" + } + """.trimIndent() + ).isEqualTo( + BrowserMessage.PublishWebviewTelemetry( + event = "{ \"metricName\": \"foo\" }" + ) + ) + + assertDeserializedInstanceOf( + """ + { + "command": "openUrl", + "externalLink": "foo" + } + """ + ).isEqualTo( + BrowserMessage.OpenUrl("foo") + ) + } + + fun `test unrecognizable command - deserialize should throw MismatchedInputException`() { + assertDeserializedWillThrow( + """ + { + "command": "" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "zxcasdqwe" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "foo bar baz" + } + """ + ) + } + + fun `test unknown fields - deserialize should throw MismatchedInputException`() { + assertDeserializedWillThrow( + """ + { + "command": "prepareUi", + "unknown": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "profileName": "foo", + "unknown": "bar" + } + """ + ) + } + + fun `test missing required fields - deserialize fail `() { + assertDeserializedWillThrow( + """ + { + "command": "selectConnection" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "accessKey": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIdC" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIdC", + "url": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIdC", + "region": "bar", + "feature": "baz" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "profileName": "bar" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "profileName": "bar", + "secretKey": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "loginIAM", + "accessKey": "foo" + } + """ + ) + + assertDeserializedWillThrow( + """ + { + "command": "openUrl" + } + """ + ) + } + + fun `test Nullable fields in sendUiClickTelemetry should not throw exception`() { + assertDoesNotThrow { + objectMapper.readValue( + """ + { + "command": "sendUiClickTelemetry", + "signInOptionClicked": null + } + """ + ) + } + + assertDoesNotThrow { + objectMapper.readValue( + """ + { + "command": "sendUiClickTelemetry" + + } + """ + ) + } + } +} diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt new file mode 100644 index 00000000000..0649bd01280 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/LoginBrowserTest.kt @@ -0,0 +1,145 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Disabled +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import software.aws.toolkits.core.telemetry.MetricEvent +import software.aws.toolkits.jetbrains.core.webview.BrowserMessage +import software.aws.toolkits.jetbrains.core.webview.BrowserState +import software.aws.toolkits.jetbrains.core.webview.LoginBrowser +import software.aws.toolkits.jetbrains.services.telemetry.MockTelemetryServiceExtension + +class TestLoginBrowser(project: Project) : LoginBrowser(project) { + // test env can't initiate a real jcef and will throw error + override val jcefBrowser: JBCefBrowserBase + get() = mock() + + override fun handleBrowserMessage(message: BrowserMessage?) {} + + override fun prepareBrowser(state: BrowserState) {} + + override fun loadWebView(query: JBCefJSQuery) {} +} + +@Disabled +class LoginBrowserTest : HeavyPlatformTestCase() { + private lateinit var sut: TestLoginBrowser + private val mockTelemetryService = MockTelemetryServiceExtension() + + override fun setUp() { + super.setUp() + mockTelemetryService.beforeEach(null) + sut = TestLoginBrowser(project) + } + + override fun tearDown() { + try { + mockTelemetryService.afterEach(null) + } finally { + super.tearDown() + } + } + fun `test publish telemetry happy path`() { + val load = """ + { + "metricName": "toolkit_didLoadModule", + "module": "login", + "result": "Succeeded", + "duration": "0" + } + """.trimIndent() + val message = BrowserMessage.PublishWebviewTelemetry(load) + sut.publishTelemetry(message) + + mockTelemetryService.batcher() + argumentCaptor { + verify(mockTelemetryService.batcher()).enqueue(capture()) + val event = requireNotNull(firstValue.data.find { it.name == "toolkit_didLoadModule" }) + assertThat(event) + .matches { it.metadata["module"] == "login" } + .matches { it.metadata["result"] == "Succeeded" } + .matches { it.metadata["duration"] == "0.0" } + } + } + fun `test publish telemetry error path`() { + val load = """ + { + "metricName": "toolkit_didLoadModule", + "module": "login", + "result": "Failed", + "reason": "unexpected error" + } + """.trimIndent() + val message = BrowserMessage.PublishWebviewTelemetry(load) + sut.publishTelemetry(message) + + mockTelemetryService.batcher() + argumentCaptor { + verify(mockTelemetryService.batcher()).enqueue(capture()) + val event = requireNotNull(firstValue.data.find { it.name == "toolkit_didLoadModule" }) + assertThat(event) + .matches { it.metadata["module"] == "login" } + .matches { it.metadata["result"] == "Failed" } + .matches { it.metadata["reason"] == "unexpected error" } + } + } + fun `test missing required field will do nothing`() { + val load = """ + { + "metricName": "toolkit_didLoadModule" + } + """.trimIndent() + val message = BrowserMessage.PublishWebviewTelemetry(load) + sut.publishTelemetry(message) + + val load1 = """ + { + "metricName": "toolkit_didLoadModule", + "module": "login" + } + """.trimIndent() + val message1 = BrowserMessage.PublishWebviewTelemetry(load1) + sut.publishTelemetry(message1) + + val load2 = """ + { + "metricName": "toolkit_didLoadModule", + "result": "Failed" + } + """.trimIndent() + val message2 = BrowserMessage.PublishWebviewTelemetry(load2) + sut.publishTelemetry(message2) + + mockTelemetryService.batcher() + argumentCaptor { + verify(mockTelemetryService.batcher(), times(0)).enqueue(capture()) + } + } + fun `test metricName doesn't match will do nothing`() { + val load = """ + { + "metricName": "foo", + "module": "login", + "result": "Failed", + "reason": "unexpected error" + } + """.trimIndent() + val message = BrowserMessage.PublishWebviewTelemetry(load) + sut.publishTelemetry(message) + + mockTelemetryService.batcher() + argumentCaptor { + verify(mockTelemetryService.batcher(), times(0)).enqueue(capture()) + } + } +} diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt new file mode 100644 index 00000000000..1d43e5b8dc3 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/credentials/DefaultToolkitAuthManagerTest.kt @@ -0,0 +1,455 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.credentials + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.replaceService +import org.assertj.core.api.Assertions.assertThat +import org.mockito.Mockito.mockConstruction +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import software.amazon.awssdk.services.ssooidc.SsoOidcClient +import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.telemetry.MetricEvent +import software.aws.toolkits.core.telemetry.TelemetryBatcher +import software.aws.toolkits.core.telemetry.TelemetryPublisher +import software.aws.toolkits.core.utils.delegateMock +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.credentials.profiles.ProfileSsoSessionIdentifier +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.InteractiveBearerTokenProvider +import software.aws.toolkits.jetbrains.services.telemetry.NoOpPublisher +import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.utils.isInstanceOf +import software.aws.toolkits.jetbrains.utils.isInstanceOfSatisfying +import software.aws.toolkits.jetbrains.utils.satisfiesKt + +class DefaultToolkitAuthManagerTest : HeavyPlatformTestCase() { + private class TestTelemetryService( + publisher: TelemetryPublisher = NoOpPublisher(), + batcher: TelemetryBatcher, + ) : TelemetryService(publisher, batcher) + + private lateinit var mockClientManager: MockClientManager + private lateinit var sut: DefaultToolkitAuthManager + private lateinit var connectionManager: ToolkitConnectionManager + private lateinit var batcher: TelemetryBatcher + private lateinit var telemetryService: TelemetryService + private var isTelemetryEnabledDefault: Boolean = false + + override fun setUp() { + super.setUp() + mockClientManager = service() as MockClientManager + + val ssoOidcClient = delegateMock() + @Suppress("DEPRECATION") + mockClientManager.register(SsoOidcClient::class, ssoOidcClient) + + sut = DefaultToolkitAuthManager() + ApplicationManager.getApplication().replaceService(ToolkitAuthManager::class.java, sut, testRootDisposable) + + connectionManager = DefaultToolkitConnectionManager(project) + project.replaceService(ToolkitConnectionManager::class.java, connectionManager, testRootDisposable) + + batcher = mock() + telemetryService = spy(TestTelemetryService(batcher = batcher)) + connectionManager = DefaultToolkitConnectionManager(project) + project.replaceService(ToolkitConnectionManager::class.java, connectionManager, testRootDisposable) + isTelemetryEnabledDefault = AwsSettings.getInstance().isTelemetryEnabled + } + + override fun tearDown() { + try { + telemetryService.dispose() + AwsSettings.getInstance().isTelemetryEnabled = isTelemetryEnabledDefault + } finally { + super.tearDown() + } + } + + fun `test creates ManagedBearerSsoConnection from ManagedSsoProfile`() { + val profile = ManagedSsoProfile( + "us-east-1", + aString(), + listOf(aString()) + ) + val connection = sut.createConnection(profile) + + assertThat(connection).isInstanceOf() + connection as ManagedBearerSsoConnection + assertThat(connection.sessionName).isEqualTo("") + assertThat(connection.region).isEqualTo(profile.ssoRegion) + assertThat(connection.startUrl).isEqualTo(profile.startUrl) + assertThat(connection.scopes).isEqualTo(profile.scopes) + } + + fun `test creates ManagedBearerSsoConnection from serialized ManagedSsoProfile`() { + val profile = ManagedSsoProfile( + "us-east-1", + aString(), + listOf(aString()) + ) + sut.createConnection(profile) + + assertThat(sut.state?.ssoProfiles).satisfiesKt { profiles -> + assertThat(profiles).isNotNull() + assertThat(profiles).singleElement().isEqualTo(profile) + } + } + + fun `test serializes ManagedSsoProfile from ManagedBearerSsoConnection`() { + val profile = ManagedSsoProfile( + "us-east-1", + aString(), + listOf(aString()) + ) + + sut.loadState( + ToolkitAuthManagerState( + ssoProfiles = listOf(profile) + ) + ) + + assertThat(sut.listConnections()).singleElement().satisfiesKt { + assertThat(it).isInstanceOfSatisfying { connection -> + assertThat(connection.sessionName).isEqualTo("") + assertThat(connection.region).isEqualTo(profile.ssoRegion) + assertThat(connection.startUrl).isEqualTo(profile.startUrl) + assertThat(connection.scopes).isEqualTo(profile.scopes) + } + } + } + + fun `test loadState dedupes profiles`() { + val profile = ManagedSsoProfile( + "us-east-1", + aString(), + listOf(aString()) + ) + + sut.loadState( + ToolkitAuthManagerState( + ssoProfiles = listOf( + profile, + profile, + profile + ) + ) + ) + + assertThat(sut.listConnections()).singleElement().satisfiesKt { + assertThat(it).isInstanceOfSatisfying { connection -> + assertThat(connection.sessionName).isEqualTo("") + assertThat(connection.region).isEqualTo(profile.ssoRegion) + assertThat(connection.startUrl).isEqualTo(profile.startUrl) + assertThat(connection.scopes).isEqualTo(profile.scopes) + } + } + } + + fun `test updates connection list from connection bus`() { + assertThat(sut.listConnections()).isEmpty() + + val scopes = listOf("scope1", "scope2") + val publisher = ApplicationManager.getApplication().messageBus.syncPublisher(CredentialManager.CREDENTIALS_CHANGED) + + publisher.ssoSessionAdded( + ProfileSsoSessionIdentifier( + "add", + "startUrl", + "us-east-1", + scopes.toSet() + ) + ) + + assertThat(sut.listConnections()).singleElement().satisfiesKt { + assertThat(it).isInstanceOfSatisfying { connection -> + assertThat(connection.sessionName).isEqualTo("add") + assertThat(connection.region).isEqualTo("us-east-1") + assertThat(connection.startUrl).isEqualTo("startUrl") + assertThat(connection.scopes).isEqualTo(scopes) + } + } + + publisher.ssoSessionModified( + ProfileSsoSessionIdentifier( + "add", + "startUrl2", + "us-east-1", + scopes.toSet() + ) + ) + + assertThat(sut.listConnections()).singleElement().satisfiesKt { + assertThat(it).isInstanceOfSatisfying { connection -> + assertThat(connection.sessionName).isEqualTo("add") + assertThat(connection.region).isEqualTo("us-east-1") + assertThat(connection.startUrl).isEqualTo("startUrl2") + assertThat(connection.scopes).isEqualTo(scopes) + } + } + + publisher.ssoSessionRemoved( + ProfileSsoSessionIdentifier( + "add", + "startUrl2", + "us-east-1", + scopes.toSet() + ) + ) + + assertThat(sut.listConnections()).isEmpty() + } + + fun `test loginSso with an working existing connection`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.state()).thenReturn(BearerTokenAuthState.AUTHORIZED) + }.use { + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("scopes") + ) + ) + + loginSso(project, "foo", "us-east-1", listOf("scopes")) + + val tokenProvider = it.constructed()[0] + verify(tokenProvider).state() + verifyNoMoreInteractions(tokenProvider) + } + } + + fun `test loginSso with an existing connection but expired and refresh token is valid, should refreshToken`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.id).thenReturn("id") + whenever(context.state()).thenReturn(BearerTokenAuthState.NEEDS_REFRESH) + }.use { + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("scopes") + ) + ) + connectionManager.switchConnection(existingConnection) + + loginSso(project, "foo", "us-east-1", listOf("scopes")) + + val tokenProvider = it.constructed()[0] + verify(tokenProvider).resolveToken() + assertThat(connectionManager.activeConnection()).isEqualTo(existingConnection) + } + } + + fun `test loginSso with an existing connection that token is invalid and there's no refresh token, should re-authenticate`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.state()).thenReturn(BearerTokenAuthState.NOT_AUTHENTICATED) + }.use { + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("scopes") + ) + ) + connectionManager.switchConnection(existingConnection) + + loginSso(project, "foo", "us-east-1", listOf("scopes")) + + val tokenProvider = it.constructed()[0] + verify(tokenProvider, timeout(5000)).reauthenticate() + assertThat(connectionManager.activeConnection()).isEqualTo(existingConnection) + } + } + + fun `test loginSso reuses connection if requested scopes are subset of existing`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.state()).thenReturn(BearerTokenAuthState.AUTHORIZED) + }.use { + val connectionManager = spy(connectionManager) + project.replaceService(ToolkitConnectionManager::class.java, connectionManager, testRootDisposable) + + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("existing1", "existing2", "existing3") + ) + ) + + connectionManager.switchConnection(existingConnection) + + loginSso(project, "foo", "us-east-1", listOf("existing1")) + + val tokenProvider = it.constructed()[0] + verify(tokenProvider).state() + verifyNoMoreInteractions(tokenProvider) + assertThat(connectionManager.activeConnection()).isEqualTo(existingConnection) + verify(connectionManager, atLeastOnce()).switchConnection(existingConnection) + } + } + + fun `test loginSso forces reauth if requested scopes are not complete subset`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + whenever(context.state()).thenReturn(BearerTokenAuthState.AUTHORIZED) + }.use { + val existingConnection = sut.createConnection( + ManagedSsoProfile( + "us-east-1", + "foo", + listOf("existing1", "existing2", "existing3") + ) + ) + + val newScopes = listOf("existing1", "new1") + loginSso(project, "foo", "us-east-1", newScopes) + + assertThat(connectionManager.activeConnection() as AwsBearerTokenConnection).satisfiesKt { connection -> + assertThat(connection.scopes.toSet()).isEqualTo(setOf("existing1", "existing2", "existing3", "new1")) + } + assertThat(sut.listConnections()).singleElement().isInstanceOfSatisfying { connection -> + assertThat(connection).usingRecursiveComparison().isNotEqualTo(existingConnection) + assertThat(connection.scopes.toSet()).isEqualTo(setOf("existing1", "existing2", "existing3", "new1")) + } + } + } + + fun `test loginSso with a new connection`() { + mockConstruction(InteractiveBearerTokenProvider::class.java) { context, _ -> + doNothing().whenever(context).reauthenticate() + whenever(context.state()).thenReturn(BearerTokenAuthState.NOT_AUTHENTICATED) + }.use { + val connectionManager = spy(connectionManager) + project.replaceService(ToolkitConnectionManager::class.java, connectionManager, testRootDisposable) + // before + assertThat(sut.listConnections()).hasSize(0) + + loginSso(project, "foo", "us-east-1", listOf("scope1", "scope2")) + + // after + assertThat(sut.listConnections()).hasSize(1) + verify(connectionManager, timeout(5000)).switchConnection(any()) + + val expectedConnection = LegacyManagedBearerSsoConnection( + "foo", + "us-east-1", + listOf("scope1", "scope2") + ) + + sut.listConnections()[0].let { conn -> + assertThat(conn.getConnectionSettings()) + .usingRecursiveComparison() + .isEqualTo(expectedConnection.getConnectionSettings()) + assertThat(conn.id).isEqualTo(expectedConnection.id) + assertThat(conn.label).isEqualTo(expectedConnection.label) + } + } + } + + fun `test logoutFromConnection should invalidate the token provider and the connection and invoke callback`() { + val profile = ManagedSsoProfile("us-east-1", "startUrl000", listOf("scopes")) + val connection = sut.createConnection(profile) as ManagedBearerSsoConnection + connectionManager.switchConnection(connection) + + var providerInvalidatedMessageReceived = 0 + var connectionSwitchedMessageReceived = 0 + var callbackInvoked = 0 + ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe( + BearerTokenProviderListener.TOPIC, + object : BearerTokenProviderListener { + override fun invalidate(providerId: String) { + if (providerId == "sso;us-east-1;startUrl000") { + providerInvalidatedMessageReceived += 1 + } + } + } + ) + ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + connectionSwitchedMessageReceived += 1 + } + } + ) + + logoutFromSsoConnection(project, connection) { callbackInvoked += 1 } + assertThat(providerInvalidatedMessageReceived).isEqualTo(1) + assertThat(connectionSwitchedMessageReceived).isEqualTo(1) + assertThat(callbackInvoked).isEqualTo(1) + } + + fun `test loginSso telemetry contains default source ID`() { + AwsSettings.getInstance().isTelemetryEnabled = true + loginSso( + project = project, + startUrl = "foo", + region = "us-east-1", + requestedScopes = listOf("scopes") + ) + val metricCaptor = argumentCaptor() + assertThat(metricCaptor.allValues).allSatisfy { event -> + assertThat(event.data.all { it.metadata["credentialSourceId"] == "awsId" }).isTrue() + } + } + + fun `test loginSso telemetry contains no source by default`() { + AwsSettings.getInstance().isTelemetryEnabled = true + loginSso( + project = project, + startUrl = "foo", + region = "us-east-1", + requestedScopes = listOf("scopes") + ) + val metricCaptor = argumentCaptor() + assertThat(metricCaptor.allValues).allSatisfy { event -> + assertThat(event.data.all { it.metadata["source"] == null }).isTrue() + } + } + + fun `test loginSso telemetry contains provided source`() { + AwsSettings.getInstance().isTelemetryEnabled = true + loginSso( + project = project, + startUrl = "foo", + region = "us-east-1", + requestedScopes = listOf("scopes"), + metadata = ConnectionMetadata("fooSource") + ) + val metricCaptor = argumentCaptor() + assertThat(metricCaptor.allValues).allSatisfy { event -> + assertThat(event.data.all { it.metadata["source"] == "fooSourceId" }).isTrue() + } + } + + fun `test serializing LegacyManagedBearerSsoConnection does not include connectionSettings`() { + val profile = ManagedSsoProfile("us-east-1", "startUrl000", listOf("scopes")) + val connection = sut.createConnection(profile) as LegacyManagedBearerSsoConnection + + assertThat(jacksonObjectMapper().writeValueAsString(connection)).doesNotContain("connectionSettings") + } + + fun `test serializing ProfileSsoManagedBearerSsoConnection does not include connectionSettings`() { + val profile = UserConfigSsoSessionProfile("sessionName", "us-east-1", "startUrl000", listOf("scopes")) + val connection = sut.createConnection(profile) as ProfileSsoManagedBearerSsoConnection + + assertThat(jacksonObjectMapper().writeValueAsString(connection)).doesNotContain("connectionSettings") + } +} diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt new file mode 100644 index 00000000000..4d794ec5692 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/IdcRolePopupTest.kt @@ -0,0 +1,100 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.openapi.components.service +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.services.sso.SsoClient +import software.amazon.awssdk.services.sso.model.RoleInfo +import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.utils.delegateMock +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.credentials.ConfigFilesFacade +import software.aws.toolkits.jetbrains.utils.satisfiesKt +import software.aws.toolkits.resources.AwsCoreBundle + +class IdcRolePopupTest : HeavyPlatformTestCase() { + private lateinit var mockClientManager: MockClientManager + + override fun setUp() { + super.setUp() + mockClientManager = service() as MockClientManager + + @Suppress("DEPRECATION") + mockClientManager.register(SsoClient::class, delegateMock()) + } + + fun `test validate role selected`() { + val state = IdcRolePopupState() + + runInEdtAndWait { + val validation = IdcRolePopup(project, aString(), aString(), mockk(), state, mockk()).run { + try { + performValidateAll() + } finally { + close(0) + } + } + + assertThat(validation).singleElement().satisfiesKt { + assertThat(it.okEnabled).isFalse() + assertThat(it.message).contains(AwsCoreBundle.message("gettingstarted.setup.error.not_selected")) + } + } + } + + fun `test success writes profile to config`() { + val sessionName = aString() + val roleInfo = RoleInfo.builder() + .roleName(aString()) + .accountId(aString()) + .build() + val state = IdcRolePopupState().apply { + this.roleInfo = roleInfo + } + val configFilesFacade = mockk { + every { readAllProfiles() } returns emptyMap() + justRun { appendProfileToConfig(any()) } + } + + runInEdtAndWait { + val sut = IdcRolePopup( + project, + region = aString(), + sessionName = sessionName, + tokenProvider = mockk(), + state = state, + configFilesFacade = configFilesFacade + ) + try { + sut.doOkActionWithRoleInfo(roleInfo) + } finally { + sut.close(0) + } + + verify { + configFilesFacade.appendProfileToConfig( + Profile.builder() + .name("$sessionName-${roleInfo.accountId()}-${roleInfo.roleName()}") + .properties( + mapOf( + "sso_session" to sessionName, + "sso_account_id" to roleInfo.accountId(), + "sso_role_name" to roleInfo.roleName() + ) + ) + .build() + ) + } + } + } +} diff --git a/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt new file mode 100644 index 00000000000..6639e5e3415 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/SetupAuthenticationDialogTest.kt @@ -0,0 +1,340 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.TestDialog +import com.intellij.openapi.ui.TestDialogManager +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.stub +import org.mockito.kotlin.whenever +import software.amazon.awssdk.profiles.Profile +import software.amazon.awssdk.services.sts.StsClient +import software.amazon.awssdk.services.sts.model.GetCallerIdentityRequest +import software.amazon.awssdk.services.sts.model.GetCallerIdentityResponse +import software.amazon.awssdk.services.sts.model.StsException +import software.aws.toolkits.core.ToolkitClientManager +import software.aws.toolkits.core.region.Endpoint +import software.aws.toolkits.core.region.Service +import software.aws.toolkits.core.utils.delegateMock +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.MockClientManager +import software.aws.toolkits.jetbrains.core.credentials.ConfigFilesFacade +import software.aws.toolkits.jetbrains.core.credentials.UserConfigSsoSessionProfile +import software.aws.toolkits.jetbrains.core.credentials.authAndUpdateConfig +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.SourceOfEntry +import software.aws.toolkits.jetbrains.core.region.MockRegionProviderExtension +import software.aws.toolkits.jetbrains.utils.satisfiesKt +import software.aws.toolkits.resources.AwsCoreBundle +import software.aws.toolkits.telemetry.FeatureId + +class SetupAuthenticationDialogTest : HeavyPlatformTestCase() { + private lateinit var mockClientManager: MockClientManager + private val mockRegionProvider = MockRegionProviderExtension() + + override fun setUp() { + super.setUp() + mockClientManager = service() as MockClientManager + } + + fun `test login to IdC tab`() { + mockkStatic(::authAndUpdateConfig) + + val startUrl = aString() + val region = mockRegionProvider.createAwsRegion() + val scopes = listOf(aString(), aString(), aString()) + mockRegionProvider.addService( + "sso", + Service( + endpoints = mapOf(region.id to Endpoint()), + isRegionalized = true, + partitionEndpoint = region.partitionId + ) + ) + + val configFacade = mockk(relaxed = true) + TestDialogManager.setTestDialog(TestDialog.OK) + val state = SetupAuthenticationDialogState().apply { + idcTabState.apply { + this.startUrl = startUrl + this.region = region + } + } + + runInEdtAndWait { + SetupAuthenticationDialog( + project, + scopes = scopes, + state = state, + configFilesFacade = configFacade, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).apply { + try { + doOKAction() + } finally { + close(0) + } + } + } + + verify { + authAndUpdateConfig( + project, + UserConfigSsoSessionProfile("", region.id, startUrl, scopes), + configFacade, + any(), + any(), + any() + ) + } + } + + fun `test login to IdC tab and request role`() { + mockkStatic(::authAndUpdateConfig) + + val startUrl = aString() + val region = mockRegionProvider.createAwsRegion() + val scopes = listOf(aString(), aString(), aString()) + mockRegionProvider.addService( + "sso", + Service( + endpoints = mapOf(region.id to Endpoint()), + isRegionalized = true, + partitionEndpoint = region.partitionId + ) + ) + + val configFacade = mockk(relaxed = true) + TestDialogManager.setTestDialog(TestDialog.OK) + val state = SetupAuthenticationDialogState().apply { + idcTabState.apply { + this.startUrl = startUrl + this.region = region + } + } + + runInEdtAndWait { + SetupAuthenticationDialog( + project, + scopes = scopes, + state = state, + promptForIdcPermissionSet = true, + configFilesFacade = configFacade, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).apply { + try { + doOKAction() + } finally { + close(0) + } + } + } + + verify { + authAndUpdateConfig( + project, + UserConfigSsoSessionProfile("", region.id, startUrl, scopes + "sso:account:access"), + configFacade, + any(), + any(), + any() + ) + } + } + + fun `test login to Builder ID tab`() { + mockkStatic(::loginSso) + every { loginSso(any(), any(), any(), any()) } answers { mockk() } + + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.BUILDER_ID) + } + + runInEdtAndWait { + SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).apply { + try { + doOKAction() + } finally { + close(0) + } + } + } + + verify { + loginSso(project, SONO_URL, SONO_REGION, emptyList()) + } + } + + fun `test validate IdC tab`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.IDENTITY_CENTER) + } + + runInEdtAndWait { + val validation = SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).run { + try { + performValidateAll() + } finally { + close(0) + } + } + + assertThat(validation).satisfiesKt { + assertThat(it).hasSize(2) + assertThat(it).allSatisfy { error -> + assertThat(error.message).contains("Must not be empty") + } + } + } + } + + fun `test validate Builder ID tab`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.BUILDER_ID) + } + + runInEdtAndWait { + val validation = SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).run { + try { + performValidateAll() + } finally { + close(0) + } + } + + assertThat(validation).isEmpty() + } + } + + fun `test validate IAM tab`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.IAM_LONG_LIVED) + iamTabState.profileName = "" + } + + runInEdtAndWait { + val validation = SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ).run { + try { + performValidateAll() + } finally { + close(0) + } + } + + assertThat(validation).satisfiesKt { + assertThat(it).hasSize(3) + assertThat(it).allSatisfy { error -> + assertThat(error.message).contains("Must not be empty") + } + } + } + } + + // TODO: Fix StsClient mock exception throwing in 2025.3 migration - this test expects an exception but mock doesn't throw + fun `test validate IAM tab fails if credentials are invalid`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.IAM_LONG_LIVED) + iamTabState.apply { + profileName = "test" + accessKey = "invalid" + secretKey = "invalid" + } + } + + val stsClient = delegateMock() + @Suppress("DEPRECATION") + mockClientManager.register(StsClient::class, stsClient) + stsClient.stub { + whenever(it.getCallerIdentity(any())).thenThrow(StsException.builder().message("Some service exception message").build()) + } + + runInEdtAndWait { + val sut = SetupAuthenticationDialog( + project, + state = state, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ) + val exception = assertThrows { sut.doOKAction() } + assertThat(exception.message).isEqualTo(AwsCoreBundle.message("gettingstarted.setup.iam.profile.invalid_credentials")) + } + } + + fun `test validate IAM tab succeeds if credentials are invalid`() { + val state = SetupAuthenticationDialogState().apply { + selectedTab.set(SetupAuthenticationTabs.IAM_LONG_LIVED) + iamTabState.apply { + profileName = "test" + accessKey = "validAccess" + secretKey = "validSecret" + } + } + + val stsClient = delegateMock() + @Suppress("DEPRECATION") + mockClientManager.register(StsClient::class, stsClient) + stsClient.stub { + whenever(it.getCallerIdentity(any())).thenReturn(GetCallerIdentityResponse.builder().build()) + } + + val configFacade = mockk(relaxed = true) + runInEdtAndWait { + SetupAuthenticationDialog( + project, + state = state, + configFilesFacade = configFacade, + sourceOfEntry = SourceOfEntry.UNKNOWN, + featureId = FeatureId.Unknown + ) + .doOKAction() + } + + verify { + configFacade.appendProfileToCredentials( + Profile.builder() + .name("test") + .properties( + mapOf( + "aws_access_key_id" to "validAccess", + "aws_secret_access_key" to "validSecret" + ) + ) + .build() + ) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt b/plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt similarity index 100% rename from plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt rename to plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt b/plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt similarity index 100% rename from plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt rename to plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt b/plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt similarity index 100% rename from plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt rename to plugins/toolkit/jetbrains-core/tst-242-252/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt new file mode 100644 index 00000000000..97622dd79ed --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindowTest.kt @@ -0,0 +1,64 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.testFramework.HeavyPlatformTestCase +import com.intellij.testFramework.runInEdtAndGet +import org.assertj.core.api.Assertions.assertThat +import software.aws.toolkits.core.utils.test.aString +import software.aws.toolkits.jetbrains.core.ToolWindowHeadlessManagerImpl + +class AwsToolkitExplorerToolWindowTest : HeavyPlatformTestCase() { + + fun `test save current tab state`() { + (ToolWindowManager.getInstance(project) as ToolWindowHeadlessManagerImpl) + .doRegisterToolWindow(AwsToolkitExplorerFactory.TOOLWINDOW_ID) + val sut = runInEdtAndGet { AwsToolkitExplorerToolWindow(project) } + + runInEdt { + sut.selectTab(AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID) + assertThat(sut.state.selectedTab).isEqualTo(AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID) + + sut.selectTab(AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID) + assertThat(sut.state.selectedTab).isEqualTo(AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID) + } + } + + fun `test load tab state`() { + (ToolWindowManager.getInstance(project) as ToolWindowHeadlessManagerImpl) + .doRegisterToolWindow(AwsToolkitExplorerFactory.TOOLWINDOW_ID) + val sut = runInEdtAndGet { AwsToolkitExplorerToolWindow(project) } + runInEdt { + sut.loadState( + AwsToolkitExplorerToolWindowState().apply { + selectedTab = + AwsToolkitExplorerToolWindow.EXPLORER_TAB_ID + } + ) + assertThat(sut.state.selectedTab).isEqualTo(AwsToolkitExplorerToolWindow.Q_TAB_ID) + + sut.loadState( + AwsToolkitExplorerToolWindowState().apply { + selectedTab = + AwsToolkitExplorerToolWindow.DEVTOOLS_TAB_ID + } + ) + assertThat(sut.state.selectedTab).isEqualTo(AwsToolkitExplorerToolWindow.Q_TAB_ID) + } + } + + fun `test handles loading invalid state`() { + (ToolWindowManager.getInstance(project) as ToolWindowHeadlessManagerImpl) + .doRegisterToolWindow(AwsToolkitExplorerFactory.TOOLWINDOW_ID) + val sut = runInEdtAndGet { AwsToolkitExplorerToolWindow(project) } + + sut.loadState( + AwsToolkitExplorerToolWindowState().apply { + selectedTab = aString() + } + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt new file mode 100644 index 00000000000..a22f9339eda --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/core/gettingstarted/GettingStartedOnStartupTest.kt @@ -0,0 +1,89 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.gettingstarted + +import com.intellij.configurationStore.getPersistentStateComponentStorageLocation +import com.intellij.testFramework.HeavyPlatformTestCase +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.touch +import software.aws.toolkits.jetbrains.core.credentials.MockCredentialManagerExtension +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel +import software.aws.toolkits.jetbrains.settings.GettingStartedSettings + +@ExperimentalCoroutinesApi +class GettingStartedOnStartupTest : HeavyPlatformTestCase() { + private val credManagerExtension = MockCredentialManagerExtension() + private val sut = GettingStartedOnStartup() + + override fun tearDown() { + try { + GettingStartedSettings.getInstance().shouldDisplayPage = true + getPersistentStateComponentStorageLocation(GettingStartedSettings::class.java)?.deleteIfExists() + } finally { + super.tearDown() + } + } + + fun `test does not show screen if aws settings exist and has credentials`() { + mockkObject(GettingStartedPanel.Companion) + every { GettingStartedPanel.openPanel(any()) } returns Unit + val fp = getPersistentStateComponentStorageLocation(GettingStartedSettings::class.java) ?: error( + "could not determine persistent storage for GettingStartedSettings" + ) + try { + fp.touch() + sut.runActivity(project) + } finally { + fp.deleteIfExists() + } + + verify(exactly = 0) { + GettingStartedPanel.openPanel(project) + } + } + + fun `test does not show screen if has previously shown screen`() { + mockkObject(GettingStartedPanel.Companion) + every { GettingStartedPanel.openPanel(any()) } returns Unit + GettingStartedSettings.getInstance().shouldDisplayPage = false + sut.runActivity(project) + + verify(exactly = 0) { + GettingStartedPanel.openPanel(project) + } + } + + fun `test shows screen if aws settings exist and no credentials`() { + mockkObject(GettingStartedPanel.Companion) + every { GettingStartedPanel.openPanel(any()) } returns Unit + credManagerExtension.clear() + val fp = getPersistentStateComponentStorageLocation(GettingStartedSettings::class.java) ?: error( + "could not determine persistent storage for GettingStartedSettings" + ) + try { + fp.touch() + sut.runActivity(project) + } finally { + fp.deleteIfExists() + } + + verify { + GettingStartedPanel.openPanel(project, any(), any()) + } + } + + fun `test shows screen on first install`() { + mockkObject(GettingStartedPanel.Companion) + every { GettingStartedPanel.openPanel(any()) } returns Unit + sut.runActivity(project) + + verify { + GettingStartedPanel.openPanel(project, any(), any()) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt new file mode 100644 index 00000000000..b11e0cfee45 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/utils/JavaTestUtils.kt @@ -0,0 +1,284 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.utils + +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.application.runWriteActionAndWait +import com.intellij.openapi.externalSystem.importing.ImportSpecBuilder +import com.intellij.openapi.externalSystem.model.DataNode +import com.intellij.openapi.externalSystem.model.project.ProjectData +import com.intellij.openapi.externalSystem.service.execution.ProgressExecutionMode +import com.intellij.openapi.externalSystem.service.project.ExternalProjectRefreshCallback +import com.intellij.openapi.externalSystem.service.project.ProjectDataManager +import com.intellij.openapi.externalSystem.settings.ExternalSystemSettingsListener +import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil +import com.intellij.openapi.externalSystem.util.ExternalSystemUtil +import com.intellij.openapi.projectRoots.JavaSdk +import com.intellij.openapi.projectRoots.ProjectJdkTable +import com.intellij.openapi.projectRoots.impl.JavaAwareProjectJdkTableImpl +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.projectRoots.impl.SdkVersionUtil +import com.intellij.openapi.roots.ModuleRootModificationUtil +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Ref +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiJavaFile +import com.intellij.testFramework.IdeaTestUtil +import com.intellij.testFramework.RunAll +import com.intellij.testFramework.runInEdtAndWait +import com.intellij.xdebugger.XDebuggerUtil +import org.jetbrains.idea.maven.model.MavenExplicitProfiles +import org.jetbrains.idea.maven.project.MavenProjectsManager +import org.jetbrains.idea.maven.server.MavenServerManager +import org.jetbrains.idea.maven.utils.MavenProgressIndicator.MavenProgressTracker +import org.jetbrains.plugins.gradle.jvmcompat.GradleJvmSupportMatrix +import org.jetbrains.plugins.gradle.settings.GradleProjectSettings +import org.jetbrains.plugins.gradle.util.GradleConstants +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.inputStream +import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.rules.addFileToModule +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.isDirectory + +fun HeavyJavaCodeInsightTestFixtureRule.setUpJdk(jdkName: String = "Real JDK"): String { + // attempt to find a JDK that works for gradle + val jdkHome = JavaSdk.getInstance().suggestHomePaths().firstOrNull { + val version = SdkVersionUtil.getJdkVersionInfo(it)?.version ?: return@firstOrNull false + version < GradleJvmSupportMatrix.getAllSupportedJavaVersionsByIdea().max() + } ?: IdeaTestUtil.requireRealJdkHome() + println("Using $jdkHome as JDK home") + + runInEdtAndWait { + runWriteAction { + VfsRootAccess.allowRootAccess(this.fixture.testRootDisposable, jdkHome) + val jdkHomeDir = LocalFileSystem.getInstance().refreshAndFindFileByPath(jdkHome)!! + val jdk = SdkConfigurationUtil.setupSdk(emptyArray(), jdkHomeDir, JavaSdk.getInstance(), false, null, jdkName)!! + + ProjectJdkTable.getInstance().addJdk(jdk, this.fixture.testRootDisposable) + ModuleRootModificationUtil.setModuleSdk(this.module, jdk) + } + } + + return jdkHome +} + +fun HeavyJavaCodeInsightTestFixtureRule.setUpGradleProject(compatibility: String = "1.8"): PsiClass { + val fixture = this.fixture + val buildFile = fixture.addFileToModule( + this.module, + "build.gradle", + """ + plugins { + id 'java' + } + + sourceCompatibility = '$compatibility' + targetCompatibility = '$compatibility' + """.trimIndent() + ).virtualFile + + // Use our project's own Gradle version + this.copyGradleFiles() + + val lambdaClass = fixture.addClass( + """ + package com.example; + + public class SomeClass { + public static String upperCase(String input) { + return input.toUpperCase(); + } + } + """.trimIndent() + ) + + val jdkName = "Gradle JDK" + setUpJdk(jdkName) + + ExternalSystemApiUtil.subscribe( + project, + GradleConstants.SYSTEM_ID, + object : ExternalSystemSettingsListener { + override fun onProjectsLinked(settings: Collection) { + super.onProjectsLinked(settings) + settings.first().gradleJvm = jdkName + } + } + ) + + val gradleProjectSettings = GradleProjectSettings().apply { + withQualifiedModuleNames() + externalProjectPath = buildFile.path + } + + val externalSystemSettings = ExternalSystemApiUtil.getSettings(project, GradleConstants.SYSTEM_ID) + externalSystemSettings.setLinkedProjectsSettings(setOf(gradleProjectSettings)) + + val error = Ref.create() + + val refreshCallback = object : ExternalProjectRefreshCallback { + override fun onSuccess(externalProject: DataNode?) { + if (externalProject == null) { + System.err.println("Got null External project after import") + return + } + ProjectDataManager.getInstance().importData(externalProject, project, true) + println("External project was successfully imported") + } + + override fun onFailure(errorMessage: String, errorDetails: String?) { + error.set(errorMessage) + } + } + + val importSpecBuilder = ImportSpecBuilder(project, GradleConstants.SYSTEM_ID) + .callback(refreshCallback) + .use(ProgressExecutionMode.MODAL_SYNC) + + ExternalSystemUtil.refreshProjects(importSpecBuilder) + + if (!error.isNull) { + error("Import failed: " + error.get()) + } + + return lambdaClass +} + +fun HeavyJavaCodeInsightTestFixtureRule.addBreakpoint() { + runInEdtAndWait { + val document = fixture.editor.document + val psiFile = fixture.file as PsiJavaFile + val body = psiFile.classes[0].allMethods[0].body!!.statements[0] + val lineNumber = document.getLineNumber(body.textOffset) + + XDebuggerUtil.getInstance().toggleLineBreakpoint( + project, + fixture.file.virtualFile, + lineNumber + ) + } +} + +private fun HeavyJavaCodeInsightTestFixtureRule.copyGradleFiles() { + val gradleRoot = findGradlew() + + // annoying and can't repro locally + val gradleWrapperHome = Paths.get(System.getProperty("user.home"), ".gradle", "wrapper").toRealPath() + if (gradleWrapperHome.exists()) { + println("Allowing vfs access to $gradleWrapperHome") + VfsRootAccess.allowRootAccess(this.fixture.testRootDisposable, gradleWrapperHome.toString()) + } + + val gradleFiles = setOf("gradle/wrapper", "gradlew.bat", "gradlew") + + gradleFiles.forEach { + val gradleFile = gradleRoot.resolve(it) + if (gradleFile.exists()) { + copyPath(gradleRoot, gradleFile) + } else { + throw IllegalStateException("Failed to locate $it") + } + } +} + +private fun HeavyJavaCodeInsightTestFixtureRule.copyPath(root: Path, path: Path) { + if (path.isDirectory()) { + Files.list(path).forEach { + // Skip over files like .DS_Store. No gradlew related files start with a "." so safe to skip + if (it.fileName.toString().startsWith(".")) { + return@forEach + } + this@copyPath.copyPath(root, it) + } + } else { + fixture.addFileToModule(module, root.relativize(path).toString(), "").also { newFile -> + runInEdtAndWait { + runWriteAction { + newFile.virtualFile.getOutputStream(null).use { out -> + path.inputStream().use { it.copyTo(out) } + } + } + } + if (SystemInfo.isUnix) { + val newPath = Paths.get(newFile.virtualFile.path) + Files.setPosixFilePermissions(newPath, Files.getPosixFilePermissions(path)) + } + } + } +} + +private fun findGradlew(): Path { + var root = Paths.get("").toAbsolutePath() + while (root.parent != null) { + if (root.resolve("gradlew").exists()) { + return root + } else { + root = root.parent + } + } + + throw IllegalStateException("Failed to locate gradlew") +} + +internal suspend fun HeavyJavaCodeInsightTestFixtureRule.setUpMavenProject(): PsiClass { + val fixture = this.fixture + val pomFile = fixture.addFileToModule( + this.module, + "pom.xml", + """ + + 4.0.0 + helloworld + HelloWorld + 1.0 + jar + A sample Hello World created for SAM CLI. + + 1.8 + 1.8 + + + """.trimIndent() + ).virtualFile + + val lambdaClass = fixture.addClass( + """ + package com.example; + + public class SomeClass { + public static String upperCase(String input) { + return input.toUpperCase(); + } + } + """.trimIndent() + ) + + Disposer.register(this.fixture.testRootDisposable) { + RunAll.runAll( + { runWriteActionAndWait { JavaAwareProjectJdkTableImpl.removeInternalJdkInTests() } }, + // unsure why we can't let connectors be closed automatically during disposer cleanup + { Disposer.dispose(MavenServerManager.getInstance()) } + ) + } + + val projectsManager = MavenProjectsManager.getInstance(project) + projectsManager.initForTests() + + val poms = listOf(pomFile) + projectsManager.addManagedFilesWithProfiles(poms, MavenExplicitProfiles.NONE, null, null, true) + + runInEdtAndWait { + project.getServiceIfCreated(MavenProgressTracker::class.java)?.waitForProgressCompletion() + // importProjects() removed in 2025.3 - project import now handled automatically by test framework + // projectsManager.importProjects() + } + return lambdaClass +} diff --git a/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt b/plugins/toolkit/jetbrains-gateway/src-242-252/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt similarity index 100% rename from plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt rename to plugins/toolkit/jetbrains-gateway/src-242-252/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt diff --git a/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt b/plugins/toolkit/jetbrains-gateway/src-242-252/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt similarity index 100% rename from plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt rename to plugins/toolkit/jetbrains-gateway/src-242-252/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt diff --git a/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt b/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt new file mode 100644 index 00000000000..693bf20ce07 --- /dev/null +++ b/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectionProvider.kt @@ -0,0 +1,617 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.gateway + +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.service +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.rd.createNestedDisposable +import com.intellij.openapi.rd.util.launchOnUi +import com.intellij.openapi.rd.util.startUnderBackgroundProgressAsync +import com.intellij.openapi.rd.util.startUnderModalProgressAsync +import com.intellij.openapi.ui.DialogBuilder +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.util.BuildNumber +import com.intellij.openapi.util.Disposer +import com.intellij.remoteDev.downloader.CodeWithMeClientDownloader +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.AlignY +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBFont +import com.jetbrains.gateway.api.ConnectionRequestor +import com.jetbrains.gateway.api.GatewayConnectionHandle +import com.jetbrains.gateway.api.GatewayConnectionProvider +import com.jetbrains.gateway.api.GatewayUI +import com.jetbrains.rd.framework.util.launch +import com.jetbrains.rd.util.lifetime.Lifetime +import com.jetbrains.rd.util.lifetime.LifetimeDefinition +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.jetbrains.concurrency.AsyncPromise +import org.jetbrains.concurrency.await +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.amazon.awssdk.services.codecatalyst.model.DevEnvironmentStatus +import software.aws.toolkits.core.utils.AttributeBagKey +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.AwsPlugin +import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.loginSso +import software.aws.toolkits.jetbrains.core.credentials.logoutFromSsoConnection +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.CODECATALYST_SCOPES +import software.aws.toolkits.jetbrains.core.credentials.sono.CodeCatalystCredentialManager +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_REGION +import software.aws.toolkits.jetbrains.core.credentials.sono.SONO_URL +import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId +import software.aws.toolkits.jetbrains.gateway.connection.GET_IDE_BACKEND_VERSION_COMMAND +import software.aws.toolkits.jetbrains.gateway.connection.GitSettings +import software.aws.toolkits.jetbrains.gateway.connection.IDE_BACKEND_DIR +import software.aws.toolkits.jetbrains.gateway.connection.caws.CawsCommandExecutor +import software.aws.toolkits.jetbrains.gateway.connection.workflow.CloneCode +import software.aws.toolkits.jetbrains.gateway.connection.workflow.CopyScripts +import software.aws.toolkits.jetbrains.gateway.connection.workflow.InstallPluginBackend.InstallLocalPluginBackend +import software.aws.toolkits.jetbrains.gateway.connection.workflow.InstallPluginBackend.InstallMarketplacePluginBackend +import software.aws.toolkits.jetbrains.gateway.connection.workflow.PrimeSshAgent +import software.aws.toolkits.jetbrains.gateway.connection.workflow.TabbedWorkflowEmitter +import software.aws.toolkits.jetbrains.gateway.connection.workflow.installBundledPluginBackend +import software.aws.toolkits.jetbrains.gateway.connection.workflow.v2.StartBackendV2 +import software.aws.toolkits.jetbrains.gateway.welcomescreen.WorkspaceListStateChangeContext +import software.aws.toolkits.jetbrains.gateway.welcomescreen.WorkspaceNotifications +import software.aws.toolkits.jetbrains.services.caws.CawsProject +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import software.aws.toolkits.jetbrains.utils.execution.steps.StepEmitter +import software.aws.toolkits.jetbrains.utils.execution.steps.StepExecutor +import software.aws.toolkits.jetbrains.utils.execution.steps.StepWorkflow +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodecatalystTelemetry +import java.net.URLDecoder +import java.time.Duration +import java.util.UUID +import javax.swing.JLabel +import kotlin.time.DurationUnit +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue +import software.aws.toolkits.telemetry.Result as TelemetryResult + +@ExperimentalTime +class CawsConnectionProvider : GatewayConnectionProvider { + private val scope = CoroutineScope(getCoroutineBgContext() + SupervisorJob()) + + companion object { + val CAWS_CONNECTION_PARAMETERS = AttributeBagKey.create>("CAWS_CONNECTION_PARAMETERS") + private val LOG = getLogger() + } + + override fun isApplicable(parameters: Map): Boolean = parameters.containsKey(CawsConnectionParameters.CAWS_ENV_ID) + + override suspend fun connect(parameters: Map, requestor: ConnectionRequestor): GatewayConnectionHandle? { + val connectionParams = try { + CawsConnectionParameters.fromParameters(parameters) + } catch (e: Exception) { + LOG.error(e) { "Caught exception while building connection settings" } + Messages.showErrorDialog(e.message ?: message("general.unknown_error"), message("caws.workspace.connection.failed")) + return null + } + + val currentConnection = service().activeConnectionForFeature(CodeCatalystConnection.getInstance()) + as AwsBearerTokenConnection? + + val ssoSettings = connectionParams.ssoSettings ?: SsoSettings(SONO_URL, SONO_REGION) + + if (currentConnection != null) { + if (ssoSettings.startUrl != currentConnection.startUrl) { + val ans = Messages.showOkCancelDialog( + message("gateway.auth.different.account.required", ssoSettings.startUrl), + message("gateway.auth.different.account.sign.in"), + message("caws.login"), + message("general.cancel"), + Messages.getErrorIcon(), + null + ) + if (ans == Messages.OK) { + logoutFromSsoConnection(project = null, currentConnection) + loginSso(project = null, ssoSettings.startUrl, ssoSettings.region, CODECATALYST_SCOPES) + } else { + return null + } + } + } + + val connectionSettings = try { + CodeCatalystCredentialManager.getInstance().getConnectionSettings() ?: error("Unable to find connection settings") + } catch (e: ProcessCanceledException) { + return null + } + + val userId = lazilyGetUserId() + + val spaceName = connectionParams.space + val projectName = connectionParams.project + val envId = connectionParams.envId + val id = WorkspaceIdentifier(CawsProject(spaceName, projectName), envId) + + val lifetime = Lifetime.Eternal.createNested() + val workflowDisposable = Lifetime.Eternal.createNestedDisposable() + + return CawsGatewayConnectionHandle(lifetime, envId) { + // reference lost with all the blocks + it.let { gatewayHandle -> + val view = JBTabbedPane() + val workflowEmitter = TabbedWorkflowEmitter(view, workflowDisposable) + + fun handleException(e: Throwable) { + if (e is ProcessCanceledException || e is CancellationException) { + CodecatalystTelemetry.connect(project = null, userId = userId, result = TelemetryResult.Cancelled) + LOG.warn { "Connect to dev environment cancelled" } + } else { + CodecatalystTelemetry.connect(project = null, userId = userId, result = TelemetryResult.Failed, reason = e.javaClass.simpleName) + LOG.error(e) { "Caught exception while connecting to dev environment" } + } + lifetime.terminate() + } + + // TODO: Describe env to validate JB ide is set on it + lifetime.launch { + try { + val cawsClient = connectionSettings.awsClient() + val environmentActions = WorkspaceActions(spaceName, projectName, envId, cawsClient) + val executor = CawsCommandExecutor(cawsClient, envId, spaceName, projectName) + + // should probably consider logging output to logger as well + // on failure we should display meaningful error and put retry button somewhere + lifetime.startUnderModalProgressAsync( + title = message("caws.connecting.waiting_for_environment"), + canBeCancelled = true, + isIndeterminate = true, + ) { + val timeBeforeEnvIsRunningCheck = System.currentTimeMillis() + var validateEnvIsRunningResult = TelemetryResult.Succeeded + var errorMessageDuringStateValidation: String? = null + try { + validateEnvironmentIsRunning(indicator, environmentActions) + } catch (e: Exception) { + validateEnvIsRunningResult = TelemetryResult.Failed + errorMessageDuringStateValidation = e.message + throw e + } finally { + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = validateEnvIsRunningResult, + duration = (System.currentTimeMillis() - timeBeforeEnvIsRunningCheck).toDouble(), + codecatalystDevEnvironmentWorkflowStep = "validateEnvRunning", + codecatalystDevEnvironmentWorkflowError = errorMessageDuringStateValidation + ) + } + + scope.launch { + ApplicationManager.getApplication().messageBus.syncPublisher(WorkspaceNotifications.TOPIC) + .environmentStarted( + WorkspaceListStateChangeContext( + WorkspaceIdentifier(CawsProject(spaceName, projectName), envId) + ) + ) + } + + val pluginPath = "$IDE_BACKEND_DIR/plugins/${AwsToolkit.PLUGINS_INFO.getValue(AwsPlugin.TOOLKIT).path?.fileName}" + var retries = 3 + val startTimeToCheckInstallation = System.currentTimeMillis() + + val toolkitInstallSettings: ToolkitInstallSettings? = coroutineScope { + while (retries > 0) { + indicator.checkCanceled() + val pluginIsInstalled = executor.remoteDirectoryExists( + pluginPath, + timeout = Duration.ofSeconds(15) + ) + + when (pluginIsInstalled) { + null -> { + if (retries == 1) { + return@coroutineScope null + } else { + retries-- + continue + } + } + + true -> return@coroutineScope ToolkitInstallSettings.None + false -> return@coroutineScope connectionParams.toolkitInstallSettings + } + } + } as ToolkitInstallSettings? + + toolkitInstallSettings ?: let { + // environment is non-responsive to SSM; restart + LOG.warn { "Restarting $envId since it appears unresponsive to SSM Run-Command" } + val timeTakenToCheckInstallation = System.currentTimeMillis() - startTimeToCheckInstallation + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = TelemetryResult.Failed, + codecatalystDevEnvironmentWorkflowStep = "ToolkitInstallationSSMCheck", + codecatalystDevEnvironmentWorkflowError = "Timeout/Unknown error while connecting to Dev Env via SSM", + duration = timeTakenToCheckInstallation.toDouble() + ) + + scope.launch { + environmentActions.stopEnvironment() + GatewayUI.getInstance().connect(parameters) + } + + gatewayHandle.terminate() + return@startUnderModalProgressAsync JLabel() + } + + lifetime.startUnderBackgroundProgressAsync(message("caws.download.thin_client"), isIndeterminate = true) { + val (backendVersion, getBackendVersionTime) = measureTimedValue { + tryOrNull { + executor.executeCommandNonInteractive( + "sh", + "-c", + GET_IDE_BACKEND_VERSION_COMMAND, + timeout = Duration.ofSeconds(15) + ).stdout + } + } + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = if (backendVersion != null) TelemetryResult.Succeeded else TelemetryResult.Failed, + duration = getBackendVersionTime.toDouble(DurationUnit.MILLISECONDS), + codecatalystDevEnvironmentWorkflowStep = "getBackendVersion" + ) + + if (backendVersion.isNullOrBlank()) { + LOG.warn { "Could not determine backend version to prefetch thin client" } + } else { + val (clientPaths, downloadClientTime) = measureTimedValue { + BuildNumber.fromStringOrNull(backendVersion)?.asStringWithoutProductCode()?.let { build -> + LOG.info { "Fetching client for version: $build" } + CodeWithMeClientDownloader.downloadClientAndJdk(build, indicator) + } + } + + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = if (clientPaths != null) TelemetryResult.Succeeded else TelemetryResult.Failed, + duration = downloadClientTime.toDouble(DurationUnit.MILLISECONDS), + codecatalystDevEnvironmentWorkflowStep = "downloadThinClient" + ) + } + } + + runBackendWorkflow( + view, + workflowEmitter, + userId, + indicator, + lifetime.createNested(), + parameters, + executor, + id, + connectionParams.gitSettings, + toolkitInstallSettings + ).await() + }.invokeOnCompletion { e -> + if (e == null) { + CodecatalystTelemetry.connect(project = null, userId = userId, result = TelemetryResult.Succeeded) + lifetime.onTermination { + Disposer.dispose(workflowDisposable) + } + } else { + handleException(e) + if (e is ProcessCanceledException || e is CancellationException) { + return@invokeOnCompletion + } + runInEdt { + DialogBuilder().apply { + setCenterPanel( + panel { + row { + icon(AllIcons.General.ErrorDialog).align(AlignY.TOP) + + panel { + row { + label(message("caws.workspace.connection.failed")).applyToComponent { + font = JBFont.regular().asBold() + } + } + + row { + label(e.message ?: message("general.unknown_error")) + } + } + } + + if (view.tabCount != 0) { + collapsibleGroup(message("general.logs"), false) { + row { + cell(view) + .align(AlignX.FILL) + } + }.expanded = false + // TODO: can't seem to reliably force a terminal redraw on initial expand + } + } + ) + + addOkAction() + addCancelAction() + okAction.setText(message("settings.retry")) + setOkOperation { + dialogWrapper.close(DialogWrapper.OK_EXIT_CODE) + GatewayUI.getInstance().connect(parameters) + } + }.show() + Disposer.dispose(workflowDisposable) + } + } + } + } catch (e: Exception) { + handleException(e) + if (e is ProcessCanceledException || e is CancellationException) { + return@launch + } + + runInEdt { + Messages.showErrorDialog(e.message ?: message("general.unknown_error"), message("caws.workspace.connection.failed")) + } + throw e + } + } + + return@let panel { + row { + cell(view) + .align(Align.FILL) + } + } + } + } + } + + private fun validateEnvironmentIsRunning( + indicator: ProgressIndicator, + environmentActions: WorkspaceActions, + ) { + when (val status = environmentActions.getEnvironmentDetails().status()) { + DevEnvironmentStatus.PENDING, DevEnvironmentStatus.STARTING -> environmentActions.waitForTaskReady(indicator) + DevEnvironmentStatus.RUNNING -> { + } + DevEnvironmentStatus.STOPPING -> { + environmentActions.waitForTaskStopped(indicator) + environmentActions.startEnvironment() + environmentActions.waitForTaskReady(indicator) + } + DevEnvironmentStatus.STOPPED -> { + environmentActions.startEnvironment() + environmentActions.waitForTaskReady(indicator) + } + DevEnvironmentStatus.DELETING, DevEnvironmentStatus.DELETED -> throw IllegalStateException("Environment is deleted, unable to start") + else -> throw IllegalStateException("Unknown state $status") + } + } + + private fun runBackendWorkflow( + view: JBTabbedPane, + workflowEmitter: TabbedWorkflowEmitter, + userId: String, + indicator: ProgressIndicator, + lifetime: LifetimeDefinition, + parameters: Map, + executor: CawsCommandExecutor, + envId: WorkspaceIdentifier, + gitSettings: GitSettings, + toolkitInstallSettings: ToolkitInstallSettings, + ): AsyncPromise { + val remoteScriptPath = "/tmp/${UUID.randomUUID()}" + val remoteProjectName = (gitSettings as? GitSettings.GitRepoSettings)?.repoName + + val steps = buildList { + add(CopyScripts(remoteScriptPath, executor)) + + when (gitSettings) { + is GitSettings.CloneGitSettings -> { + if (gitSettings.repo.scheme == "ssh") { + // TODO: we should probably use JB's SshConnectionService/ConnectionBuilder since they have better ssh agent support than we could write + add(PrimeSshAgent(gitSettings)) + } + add(CloneCode(remoteScriptPath, gitSettings, executor)) + } + + is GitSettings.CawsOwnedRepoSettings, + is GitSettings.NoRepo, + -> { + } + } + + when (toolkitInstallSettings) { + is ToolkitInstallSettings.None -> {} + is ToolkitInstallSettings.UseSelf -> { + add(installBundledPluginBackend(executor, remoteScriptPath, IDE_BACKEND_DIR)) + } + is ToolkitInstallSettings.UseArbitraryLocalPath -> { + add(InstallLocalPluginBackend(toolkitInstallSettings, executor, remoteScriptPath, IDE_BACKEND_DIR)) + } + is ToolkitInstallSettings.UseMarketPlace -> { + add(InstallMarketplacePluginBackend(null, executor, remoteScriptPath, IDE_BACKEND_DIR)) + } + } + + add(StartBackendV2(lifetime, indicator, envId, remoteProjectName)) + } + + val promise = AsyncPromise() + fun start() { + lifetime.launchOnUi { + view.removeAll() + } + + indicator.fraction = 0.0 + val workflow = object : StepWorkflow(steps) { + override fun execute(context: Context, stepEmitter: StepEmitter, ignoreCancellation: Boolean) { + runInEdt(ModalityState.any()) { + indicator.isIndeterminate = false + } + + topLevelSteps.forEachIndexed { i, step -> + indicator.checkCanceled() + runInEdt(ModalityState.any()) { + indicator.fraction = i.toDouble() / steps.size + indicator.text = step.stepName + } + + val start = System.currentTimeMillis() + var error: Throwable? = null + try { + step.run(context, stepEmitter) + } catch (e: Throwable) { + error = e + throw e + } finally { + val time = System.currentTimeMillis() - start + LOG.info { "${step.stepName} took ${time}ms" } + + val result = when (error) { + null -> TelemetryResult.Succeeded + is ProcessCanceledException, is CancellationException -> TelemetryResult.Cancelled + else -> TelemetryResult.Failed + } + + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = result, + duration = time.toDouble(), + codecatalystDevEnvironmentWorkflowStep = step.stepName, + codecatalystDevEnvironmentWorkflowError = error?.javaClass?.simpleName + ) + } + } + } + } + + StepExecutor(project = null, workflow, workflowEmitter) + .also { + it.addContext(CAWS_CONNECTION_PARAMETERS, parameters) + lifetime.onTermination { + it.getProcessHandler().destroyProcess() + } + + it.onSuccess = { + promise.setResult(Unit) + } + + it.onError = { throwable -> + promise.setError(throwable) + } + + it.startExecution() + } + } + + start() + + return promise + } +} + +data class CawsConnectionParameters( + val space: String, + val project: String, + val envId: String, + val gitSettings: GitSettings, + val toolkitInstallSettings: ToolkitInstallSettings, + val ssoSettings: SsoSettings?, +) { + companion object { + const val CAWS_SPACE = "aws.codecatalyst.space" + const val CAWS_PROJECT = "aws.codecatalyst.project" + const val CAWS_ENV_ID = "aws.codecatalyst.env.id" + const val CAWS_GIT_REPO_NAME = "aws.codecatalyst.git.repo.name" + const val CAWS_UNLINKED_GIT_REPO_URL = "aws.caws.unlinked.git.repo.url" + const val CAWS_UNLINKED_GIT_REPO_BRANCH = "aws.caws.unlinked.git.repo.branch" + const val DEV_SETTING_USE_BUNDLED_TOOLKIT = "aws.caws.dev.use.bundled.toolkit" + const val DEV_SETTING_TOOLKIT_PATH = "aws.caws.dev.toolkit.path" + const val DEV_SETTING_S3_STAGING = "aws.caws.dev.s3.staging" + const val SSO_START_URL = "sso_start_url" + const val SSO_REGION = "sso_region" + + fun fromParameters(parameters: Map): CawsConnectionParameters { + val spaceName = parameters[CAWS_SPACE] ?: error("Missing required parameter: CAWS space name") + val projectName = parameters[CAWS_PROJECT] ?: throw IllegalStateException("Missing required parameter: CAWS project name") + val envId = parameters[CAWS_ENV_ID] ?: throw IllegalStateException("Missing required parameter: CAWS environment id") + val repoName = parameters[CAWS_GIT_REPO_NAME] + val gitRepoUrl = parameters[CAWS_UNLINKED_GIT_REPO_URL] + val gitRepoBranch = parameters[CAWS_UNLINKED_GIT_REPO_BRANCH] + val useBundledToolkit = parameters[DEV_SETTING_USE_BUNDLED_TOOLKIT]?.toBoolean() + val toolkitPath = parameters[DEV_SETTING_TOOLKIT_PATH] + val s3StagingBucket = parameters[DEV_SETTING_S3_STAGING] + val ssoStartUrl = parameters[SSO_START_URL] + val ssoRegion = parameters[SSO_REGION] + + val gitSettings = + if (repoName != null) { + GitSettings.CawsOwnedRepoSettings(repoName) + } else if (!gitRepoUrl.isNullOrEmpty() && !gitRepoBranch.isNullOrEmpty()) { + GitSettings.CloneGitSettings(gitRepoUrl, gitRepoBranch) + } else { + GitSettings.NoRepo + } + + val providedInstallSettings = + if (useBundledToolkit == true) { + ToolkitInstallSettings.UseSelf + } else if (toolkitPath?.isNotBlank() == true && s3StagingBucket?.isNotBlank() == true) { + ToolkitInstallSettings.UseArbitraryLocalPath(toolkitPath, s3StagingBucket) + } else { + ToolkitInstallSettings.UseMarketPlace + } + + val ssoSettings = if (ssoStartUrl != null && ssoRegion != null) { + SsoSettings.fromUrlParameters(ssoStartUrl, ssoRegion) + } else { + null + } + + return CawsConnectionParameters( + spaceName, + projectName, + envId, + gitSettings, + providedInstallSettings, + ssoSettings + ) + } + } +} + +data class SsoSettings( + val startUrl: String, + val region: String, +) { + companion object { + fun fromUrlParameters(startUrl: String, region: String) = SsoSettings(URLDecoder.decode(startUrl, "UTF-8"), region) + } +} diff --git a/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt b/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt new file mode 100644 index 00000000000..ba7b92f3367 --- /dev/null +++ b/plugins/toolkit/jetbrains-gateway/src-253+/software/aws/toolkits/jetbrains/gateway/CawsConnectorViewPanels.kt @@ -0,0 +1,646 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.gateway + +import com.intellij.ide.browsers.BrowserLauncher +import com.intellij.openapi.components.service +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.rd.createNestedDisposable +import com.intellij.openapi.rd.util.launchOnUi +import com.intellij.openapi.rd.util.startWithModalProgressAsync +import com.intellij.openapi.rd.util.withUiContext +import com.intellij.openapi.ui.DialogPanel +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.setEmptyState +import com.intellij.openapi.util.Disposer +import com.intellij.platform.ide.progress.ModalTaskOwner +import com.intellij.platform.ide.progress.TaskCancellation +import com.intellij.platform.util.progress.indeterminateStep +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLoadingPanel +import com.intellij.ui.components.JBRadioButton +import com.intellij.ui.components.panels.Wrapper +import com.intellij.ui.dsl.builder.BottomGap +import com.intellij.ui.dsl.builder.COLUMNS_MEDIUM +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.Row +import com.intellij.ui.dsl.builder.TopGap +import com.intellij.ui.dsl.builder.bind +import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.selected +import com.intellij.ui.dsl.builder.toMutableProperty +import com.intellij.ui.layout.not +import com.intellij.ui.layout.selected +import com.jetbrains.gateway.api.GatewayUI +import com.jetbrains.gateway.welcomeScreen.MultistagePanel +import com.jetbrains.gateway.welcomeScreen.MultistagePanelContainer +import com.jetbrains.gateway.welcomeScreen.MultistagePanelDelegate +import com.jetbrains.rd.util.lifetime.Lifetime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.amazon.awssdk.services.codecatalyst.model.InstanceType +import software.aws.toolkits.core.ClientConnectionSettings +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.AwsClientManager +import software.aws.toolkits.jetbrains.core.AwsResourceCache +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConnection +import software.aws.toolkits.jetbrains.core.credentials.sono.lazilyGetUserId +import software.aws.toolkits.jetbrains.gateway.connection.IdeBackendActions +import software.aws.toolkits.jetbrains.gateway.welcomescreen.recursivelySetBackground +import software.aws.toolkits.jetbrains.gateway.welcomescreen.setDefaultBackgroundAndBorder +import software.aws.toolkits.jetbrains.isDeveloperMode +import software.aws.toolkits.jetbrains.services.caws.CawsCodeRepository +import software.aws.toolkits.jetbrains.services.caws.CawsEndpoints +import software.aws.toolkits.jetbrains.services.caws.CawsProject +import software.aws.toolkits.jetbrains.services.caws.CawsResources +import software.aws.toolkits.jetbrains.services.caws.InactivityTimeout +import software.aws.toolkits.jetbrains.services.caws.isSubscriptionFreeTier +import software.aws.toolkits.jetbrains.services.caws.isSupportedInFreeTier +import software.aws.toolkits.jetbrains.services.caws.listAccessibleProjectsPaginator +import software.aws.toolkits.jetbrains.services.caws.loadParameterDescriptions +import software.aws.toolkits.jetbrains.settings.CawsSpaceTracker +import software.aws.toolkits.jetbrains.ui.AsyncComboBox +import software.aws.toolkits.jetbrains.utils.ui.find +import software.aws.toolkits.jetbrains.utils.ui.selected +import software.aws.toolkits.resources.message +import software.aws.toolkits.telemetry.CodecatalystCreateDevEnvironmentRepoType +import software.aws.toolkits.telemetry.CodecatalystTelemetry +import java.awt.BorderLayout +import java.awt.event.ItemEvent +import javax.swing.JComponent +import software.aws.toolkits.telemetry.Result as TelemetryResult + +class CawsSettings( + // core bindings + var project: CawsProject? = null, + var productType: GatewayProduct? = null, + var linkedRepoName: String? = null, + var linkedRepoBranch: BranchSummary? = null, + var createBranchName: String = "", + var unlinkedRepoUrl: String = "", + var unlinkedRepoBranch: String? = null, + var alias: String = "", + var cloneType: CawsWizardCloneType = CawsWizardCloneType.NONE, + var instanceType: InstanceType = InstanceType.DEV_STANDARD1_SMALL, + var persistentStorage: Int? = 0, + var inactivityTimeout: InactivityTimeout = InactivityTimeout.DEFAULT_TIMEOUT, + + // dev settings + var useBundledToolkit: Boolean = false, + var s3StagingBucket: String = "", + var toolkitLocation: String = "", + + // intermediate values + var connectionSettings: ClientConnectionSettings<*>? = null, + var branchCloneType: BranchCloneType = BranchCloneType.EXISTING, + var is3P: Boolean = false, +) + +fun cawsWizard(lifetime: Lifetime, settings: CawsSettings = CawsSettings()) = MultistagePanelContainer( + listOf( + CawsInstanceSetupPanel(lifetime) + ), + settings, + object : MultistagePanelDelegate { + override fun onMultistagePanelBack(context: CawsSettings) { + GatewayUI.getInstance().reset() + CodecatalystTelemetry.createDevEnvironment(project = null, userId = lazilyGetUserId(), result = TelemetryResult.Cancelled) + } + + override fun onMultistagePanelDone(context: CawsSettings) { + val productType = context.productType ?: throw RuntimeException("CAWS wizard finished but productType was not set") + val connectionSettings = context.connectionSettings ?: throw RuntimeException("CAWS wizard finished but connectionSettings was not set") + + lifetime.startWithModalProgressAsync( + owner = ModalTaskOwner.guess(), + title = message("caws.creating_workspace"), + cancellation = TaskCancellation.nonCancellable() + ) { + val userId = lazilyGetUserId() + val start = System.currentTimeMillis() + val env = try { + val cawsClient = connectionSettings.awsClient() + if (context.cloneType == CawsWizardCloneType.UNLINKED_3P) { + error("Not implemented") + } + + if (context.is3P) { + context.branchCloneType = BranchCloneType.EXISTING + } + + if (context.branchCloneType == BranchCloneType.NEW_FROM_EXISTING) { + indeterminateStep(message("caws.creating_branch")) { + cawsClient.createSourceRepositoryBranch { + val project = context.project ?: throw RuntimeException("project was null") + val commitId = context.linkedRepoBranch?.headCommitId ?: throw RuntimeException("source commit id was not defined") + it.spaceName(project.space) + it.projectName(project.project) + it.sourceRepositoryName(context.linkedRepoName) + it.name(context.createBranchName) + it.headCommitId(commitId) + } + } + } + + IdeBackendActions.createWorkspace(cawsClient, context).also { + val repoType = when (context.cloneType) { + CawsWizardCloneType.CAWS -> CodecatalystCreateDevEnvironmentRepoType.Linked + CawsWizardCloneType.UNLINKED_3P -> CodecatalystCreateDevEnvironmentRepoType.Unlinked + CawsWizardCloneType.NONE -> CodecatalystCreateDevEnvironmentRepoType.None + } + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = TelemetryResult.Succeeded, + duration = (System.currentTimeMillis() - start).toDouble(), + codecatalystDevEnvironmentWorkflowStep = "createDevEnvironment" + ) + CodecatalystTelemetry.createDevEnvironment( + project = null, + userId = userId, + codecatalystCreateDevEnvironmentRepoType = repoType, + result = TelemetryResult.Succeeded + ) + } + } catch (e: Exception) { + val message = message("caws.workspace.creation.failed") + getLogger().error(e) { message } + withUiContext { + Messages.showErrorDialog(e.message ?: message("general.unknown_error"), message) + } + CodecatalystTelemetry.devEnvironmentWorkflowStatistic( + project = null, + userId = userId, + result = TelemetryResult.Failed, + duration = (System.currentTimeMillis() - start).toDouble(), + codecatalystDevEnvironmentWorkflowStep = "createDevEnvironment" + ) + CodecatalystTelemetry.createDevEnvironment(project = null, userId = userId, result = TelemetryResult.Failed) + return@startWithModalProgressAsync + } + + val currentConnection = service() + .activeConnectionForFeature(CodeCatalystConnection.getInstance()) as AwsBearerTokenConnection? + ?: error("Connection cannot be null") + + val parameters = mapOf( + CawsConnectionParameters.CAWS_SPACE to env.identifier.project.space, + CawsConnectionParameters.CAWS_PROJECT to env.identifier.project.project, + CawsConnectionParameters.CAWS_ENV_ID to env.identifier.id, + CawsConnectionParameters.DEV_SETTING_USE_BUNDLED_TOOLKIT to context.useBundledToolkit.toString(), + CawsConnectionParameters.DEV_SETTING_S3_STAGING to context.s3StagingBucket, + CawsConnectionParameters.DEV_SETTING_TOOLKIT_PATH to context.toolkitLocation, + CawsConnectionParameters.SSO_START_URL to currentConnection.startUrl, + CawsConnectionParameters.SSO_REGION to currentConnection.region + ) + buildMap { + when (context.cloneType) { + CawsWizardCloneType.CAWS -> { + val repoName = context.linkedRepoName ?: throw RuntimeException("CAWS wizard finished but linkedRepoName was not set") + put(CawsConnectionParameters.CAWS_GIT_REPO_NAME, repoName) + } + + CawsWizardCloneType.UNLINKED_3P -> { + val branch = context.unlinkedRepoBranch ?: throw RuntimeException("CAWS wizard finished but unlinkedRepoBranch was not set") + put(CawsConnectionParameters.CAWS_UNLINKED_GIT_REPO_URL, context.unlinkedRepoUrl) + put(CawsConnectionParameters.CAWS_UNLINKED_GIT_REPO_BRANCH, branch) + } + + CawsWizardCloneType.NONE -> {} + } + } + + withUiContext { + GatewayUI.getInstance().connect(parameters) + } + } + } + } +) + +class CawsInstanceSetupPanel(private val lifetime: Lifetime) : MultistagePanel { + private lateinit var panel: EnvironmentDetailsPanel + + override fun getComponent(context: CawsSettings): JComponent { + panel = EnvironmentDetailsPanel(context, lifetime) + return panel.getComponent() + } + + override fun init(context: CawsSettings, canGoBackAndForthConsumer: (Boolean, Boolean) -> Unit) { + } + + override fun onEnter(context: CawsSettings, isForward: Boolean) {} + + override suspend fun onGoingToLeave(context: CawsSettings, isForward: Boolean): Boolean { + if (isForward) { + return panel.runValidation() + } + + return true + } + + override fun onLeave(context: CawsSettings, isForward: Boolean) {} + + override fun shouldSkip(context: CawsSettings, isForward: Boolean) = false + + override fun forwardButtonText(): String = message("caws.create_workspace") +} + +class EnvironmentDetailsPanel(private val context: CawsSettings, lifetime: Lifetime) : CawsLoadingPanel(lifetime) { + private val disposable = lifetime.createNestedDisposable() + private val environmentParameters = loadParameterDescriptions().environmentParameters + private lateinit var createPanel: DialogPanel + + override val title = context.project?.let { message("caws.workspace.details.project_specific_title", it.project) } + ?: message("caws.workspace.details.title") + + override fun getContent(connectionSettings: ClientConnectionSettings<*>): JComponent { + context.connectionSettings = connectionSettings + val client = AwsClientManager.getInstance().getClient(connectionSettings) + val spaces = getSpaces(client) + return if (spaces.isEmpty()) { + infoPanel() + .addLine(message("caws.workspace.details.introduction_message")) + .addAction(message("general.get_started")) { + BrowserLauncher.instance.browse(CawsEndpoints.ConsoleFactory.baseUrl()) + } + .addAction(message("general.refresh")) { lifetime.launchOnUi { startLoading() } } + } else { + panel { + row(message("caws.workspace.ide_label")) { + bottomGap(BottomGap.MEDIUM) + ideVersionComboBox(disposable, context::productType) + } + + lateinit var branchOptions: Row + lateinit var newBranchOption: Cell + lateinit var newBranch: Row + lateinit var cloneRepoButton: Cell + val existingProject = context.project + val existingRepo = context.linkedRepoName + + if (existingRepo != null) { + context.cloneType = CawsWizardCloneType.CAWS + } + + panel { + group(message("caws.workspace.settings.repository_header"), indent = false) { + row { + comment(message("caws.workspace.clone.info_repo")) + } + + buttonsGroup { + row { + cloneRepoButton = radioButton(message("caws.workspace.details.clone_repo"), CawsWizardCloneType.CAWS).applyToComponent { + isSelected = context.cloneType == CawsWizardCloneType.CAWS + } + + radioButton(message("caws.workspace.details.create_empty_dev_env"), CawsWizardCloneType.NONE).applyToComponent { + isSelected = context.cloneType == CawsWizardCloneType.NONE + } + } + }.bind({ context.cloneType }, { context.cloneType = it }) + + row { + label(message("caws.workspace.clone.info")) + }.visibleIf(cloneRepoButton.selected) + + val projectCombo = AsyncComboBox { label, value, _ -> + value ?: return@AsyncComboBox + label.text = "${value.project} (${value.space})" + } + Disposer.register(disposable, projectCombo) + + row(message("caws.project")) { + cell(projectCombo) + .bindItem(context::project.toMutableProperty()) + .errorOnApply(message("caws.workspace.details.project_validation")) { it.selectedItem == null } + .columns(COLUMNS_MEDIUM) + } + + val linkedRepoCombo = AsyncComboBox { label, value, _ -> label.text = value?.name } + val linkedBranchCombo = AsyncComboBox { label, value, _ -> label.text = value?.name } + Disposer.register(disposable, linkedRepoCombo) + Disposer.register(disposable, linkedBranchCombo) + + row(message("caws.repository")) { + cell(linkedRepoCombo) + .bind( + { it.selected()?.name }, + { i, v -> i.selectedItem = i.model.find { it.name == v } }, + context::linkedRepoName.toMutableProperty() + ) + .errorOnApply(message("caws.workspace.details.repository_validation")) { it.isVisible && it.selectedItem == null } + .columns(COLUMNS_MEDIUM) + projectCombo.addActionListener { + linkedRepoCombo.proposeModelUpdate { model -> + projectCombo.selected()?.let { project -> + val repositories = getRepoNames(project, client) + repositories.forEach { model.addElement(it) } + } + } + } + }.visibleIf(cloneRepoButton.selected) + + if (!existingRepo.isNullOrEmpty()) { + linkedBranchCombo.proposeModelUpdate { model -> + val project = existingProject ?: throw RuntimeException("existingProject was null after null check") + getBranchNames(project, existingRepo, client).forEach { model.addElement(it) } + } + } + + panel { + row { + label(message("caws.workspace.details.branch_title")) + } + + row { comment(message("caws.workspace.details.create_branch_comment")) } + + buttonsGroup { + branchOptions = row { + newBranchOption = radioButton(message("caws.workspace.details.branch_new"), BranchCloneType.NEW_FROM_EXISTING) + .applyToComponent { + isSelected = context.branchCloneType == BranchCloneType.NEW_FROM_EXISTING + }.bindSelected( + { context.branchCloneType == BranchCloneType.NEW_FROM_EXISTING }, + { if (it) context.branchCloneType = BranchCloneType.NEW_FROM_EXISTING } + ) + + radioButton(message("caws.workspace.details.branch_existing"), BranchCloneType.EXISTING) + .applyToComponent { + isSelected = context.branchCloneType == BranchCloneType.EXISTING + }.bindSelected( + { context.branchCloneType == BranchCloneType.EXISTING }, + { if (it) context.branchCloneType = BranchCloneType.EXISTING } + ) + }.apply { visible(cloneRepoButton.component.isSelected) } + }.bind({ context.branchCloneType }, { context.branchCloneType = it }) + + newBranch = row(message("caws.workspace.details.branch_new")) { + textField().bindText(context::createBranchName) + .errorOnApply(message("caws.workspace.details.branch_new_validation")) { + it.isVisible && it.text.isNullOrBlank() + } + }.visibleIf(newBranchOption.selected) + + row(message("caws.workspace.details.branch_existing")) { + cell(linkedBranchCombo) + .bindItem(context::linkedRepoBranch.toMutableProperty()) + .errorOnApply(message("caws.workspace.details.branch_validation")) { it.isVisible && it.selectedItem == null } + .columns(COLUMNS_MEDIUM) + + linkedRepoCombo.addActionListener { + linkedBranchCombo.proposeModelUpdate { model -> + projectCombo.selected()?.let { project -> + linkedRepoCombo.selected()?.let { repo -> + // janky nonsense because there's no good way to model this though the component predicate system + context.is3P = isRepo3P(project, repo.name) + branchOptions.visible(!context.is3P) + + val branches = getBranchNames(project, repo.name, client) + branches.forEach { model.addElement(it) } + } + } + } + } + contextHelp(message("caws.one.branch.per.dev.env.comment")) + } + }.visibleIf(cloneRepoButton.selected) + + // need here to force comboboxes to load + getProjects(client, spaces).apply { + forEach { projectCombo.addItem(it) } + projectCombo.selectedItem = existingProject + ?: firstOrNull { it.space == CawsSpaceTracker.getInstance().lastSpaceName() } + ?: firstOrNull() + } + + val propertyGraph = PropertyGraph() + val projectProperty = propertyGraph.property(projectCombo.selected()) + projectCombo.addItemListener { + if (it.stateChange == ItemEvent.SELECTED) { + projectProperty.set(it.item as CawsProject?) + } + } + + row(message("caws.workspace.details.alias.label")) { + topGap(TopGap.MEDIUM) + // TODO: would be nice to have mutable combobox with existing projects + textField() + .bindText(context::alias) + .columns(COLUMNS_MEDIUM) + .applyToComponent { + setEmptyState(message("general.optional")) + } + }.contextHelp(message("caws.alias.instruction.text")) + + row { + placeholder() + }.bottomGap(BottomGap.MEDIUM) + + group(message("caws.workspace.settings"), indent = false) { + row { + val wrapper = Wrapper().apply { isOpaque = false } + val loadingPanel = JBLoadingPanel(BorderLayout(), disposable).apply { + add(wrapper, BorderLayout.CENTER) + } + val content = { space: String? -> + envConfigPanel(space?.let { isSubscriptionFreeTier(client, it) } ?: false) + } + + wrapper.setContent(content(projectProperty.get()?.space)) + + val getDialogPanel = { wrapper.targetComponent as DialogPanel } + cell(loadingPanel) + .onApply { getDialogPanel().apply() } + .onReset { getDialogPanel().reset() } + .onIsModified { getDialogPanel().isModified() } + + projectProperty.afterChange { project -> + lifetime.launchOnUi { + loadingPanel.startLoading() + var panel: JComponent? = null + CoroutineScope(getCoroutineBgContext() + SupervisorJob()).launch { + panel = content(project?.space) + } + panel?.let { wrapper.setContent(it) } + loadingPanel.stopLoading() + } + } + } + } + + if (isDeveloperMode()) { + group(message("caws.workspace.details.developer_tool_settings")) { + lateinit var useBundledToolkit: Cell + row { + useBundledToolkit = checkBox(message("caws.workspace.details.use_bundled_toolkit")).bindSelected(context::useBundledToolkit) + } + + panel { + row(message("caws.workspace.details.backend_toolkit_location")) { + textFieldWithBrowseButton( + fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFileDescriptor().withTitle( + message("caws.workspace.details.toolkit_location") + ) + ).bindText(context::toolkitLocation) + } + + row(message("caws.workspace.details.s3_bucket")) { + textField() + .bindText(context::s3StagingBucket) + .columns(COLUMNS_MEDIUM) + } + }.visibleIf(useBundledToolkit.selected.not()) + } + } + } + } + }.also { + setDefaultBackgroundAndBorder(it) + it.registerValidators(disposable) + createPanel = it + }.let { + ScrollPaneFactory.createScrollPane(it, true) + } + } + } + + private fun getSpaces(client: CodeCatalystClient) = client.listSpacesPaginator { } + .items() + .map { it.name() } + + private fun getProjects(client: CodeCatalystClient, spaces: List) = spaces + .flatMap { space -> + client.listAccessibleProjectsPaginator { it.spaceName(space) }.items() + .map { project -> CawsProject(space, project.name()) } + } + .sortedByDescending { it.project } + + private fun getRepoNames(project: CawsProject, client: CodeCatalystClient) = client.listSourceRepositoriesPaginator { + it.spaceName(project.space) + it.projectName(project.project) + } + .items() + .map { it.toSourceRepository() } + .sortedBy { it.name } + + private fun isRepo3P(project: CawsProject, repo: String): Boolean { + val connectionSettings = context.connectionSettings ?: throw RuntimeException("ConnectionSettings was not set") + val url = AwsResourceCache.getInstance().getResource( + CawsResources.cloneUrls(CawsCodeRepository(project.space, project.project, repo)), + connectionSettings + ).toCompletableFuture().get() + return !CawsEndpoints.isCawsGit(url) + } + + private fun getBranchNames(project: CawsProject, repo: String, client: CodeCatalystClient) = + client.listSourceRepositoryBranchesPaginator { + it.spaceName(project.space) + it.projectName(project.project) + it.sourceRepositoryName(repo) + } + .items() + .map { summary -> + val branchName = summary.name() + + BranchSummary( + if (branchName.startsWith(BRANCH_PREFIX)) { + branchName.substringAfter(BRANCH_PREFIX) + } else { + branchName + }, + summary.headCommitId() + ) + } + .sortedBy { it.name } + + private fun envConfigPanel(isFreeTier: Boolean) = + panel { + if (isFreeTier) { + row { + comment(message("caws.compute.size.in.free.tier.comment")) + } + } + + cawsEnvironmentSize( + environmentParameters, + context::instanceType, + isFreeTier + ) + + row { + label(message("caws.workspace.details.persistent_storage_title")) + comboBox( + PersistentStorageOptions(environmentParameters.persistentStorageSize.filter { it > 0 }, isFreeTier), + SimpleListCellRenderer.create { label, value, _ -> + label.isEnabled = if (isFreeTier) { + value.isSupportedInFreeTier() + } else { + true + } + label.text = message("caws.storage.value", value) + } + ).bindItem(context::persistentStorage.toMutableProperty()) + }.bottomGap(BottomGap.MEDIUM).contextHelp(message("caws.workspace.details.persistent_storage_comment")) + + row { + cawsEnvironmentTimeout(context::inactivityTimeout) + }.contextHelp(message("caws.workspace.details.inactivity_timeout_comment")) + }.apply { + recursivelySetBackground(this) + } + + fun runValidation(): Boolean { + try { + if (createPanel.validateAll().isEmpty()) { + createPanel.apply() + return true + } + } catch (e: UninitializedPropertyAccessException) { // error is displayed on the panel + } + return false + } + + companion object { + const val BRANCH_PREFIX = "refs/heads/" + } +} + +enum class CawsWizardCloneType { + CAWS, + UNLINKED_3P, + NONE, +} + +enum class BranchCloneType { + EXISTING, + NEW_FROM_EXISTING, +} + +class PersistentStorageOptions(items: List, private val subscriptionIsFreeTier: Boolean) : CollectionComboBoxModel(items) { + override fun setSelectedItem(item: Any?) { + if (subscriptionIsFreeTier) { + if (item != 16) { + super.setSelectedItem(16) + } + } else { + super.setSelectedItem(item) + } + } +} diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt b/plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt rename to plugins/toolkit/jetbrains-ultimate/src-242-252/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt new file mode 100644 index 00000000000..60f22f29b59 --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/CodeCatalystGatewayClientCustomizer.kt @@ -0,0 +1,35 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.remoteDev.caws + +import com.intellij.openapi.extensions.ExtensionNotApplicableException +import com.jetbrains.rdserver.unattendedHost.customization.DefaultGatewayExitCustomizationProvider +import com.jetbrains.rdserver.unattendedHost.customization.GatewayClientCustomizationProvider +import com.jetbrains.rdserver.unattendedHost.customization.GatewayExitCustomizationProvider +import com.jetbrains.rdserver.unattendedHost.customization.controlCenter.GatewayControlCenterProvider +import com.jetbrains.rdserver.unattendedHost.customization.controlCenter.GatewayHostnameDisplayKind +import icons.AwsIcons +import software.aws.toolkits.jetbrains.utils.isCodeCatalystDevEnv +import software.aws.toolkits.resources.message + +class CodeCatalystGatewayClientCustomizer : GatewayClientCustomizationProvider { + init { + if (!isCodeCatalystDevEnv()) { + throw ExtensionNotApplicableException.create() + } + } + + override val controlCenter: GatewayControlCenterProvider = object : GatewayControlCenterProvider { + override fun getHostnameDisplayKind() = GatewayHostnameDisplayKind.ShowHostnameOnNavbar + override fun getHostnameLong() = title + override fun getHostnameShort() = title + } + + override val icon = AwsIcons.Logos.CODE_CATALYST_SMALL + override val title = message("caws.workspace.backend.title") + + override val exitCustomization: GatewayExitCustomizationProvider = object : GatewayExitCustomizationProvider by DefaultGatewayExitCustomizationProvider() { + override val isEnabled: Boolean = false + } +} diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt new file mode 100644 index 00000000000..4e473f5052e --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/DevEnvStatusWatcher.kt @@ -0,0 +1,140 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.remoteDev.caws + +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity +import com.intellij.openapi.ui.MessageDialogBuilder +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.awsClient +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext +import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope +import software.aws.toolkits.jetbrains.core.credentials.sono.CodeCatalystCredentialManager +import software.aws.toolkits.jetbrains.services.caws.CawsConstants +import software.aws.toolkits.jetbrains.services.caws.envclient.CawsEnvironmentClient +import software.aws.toolkits.jetbrains.services.caws.envclient.models.UpdateActivityRequest +import software.aws.toolkits.jetbrains.utils.isCodeCatalystDevEnv +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message +import java.time.Instant +import java.time.temporal.ChronoUnit + +class DevEnvStatusWatcher : StartupActivity { + + companion object { + private val LOG = getLogger() + } + + override fun runActivity(project: Project) { + if (!isCodeCatalystDevEnv()) { + return + } + val connection = CodeCatalystCredentialManager.getInstance(project).getConnectionSettings() + ?: error("Failed to fetch connection settings from Dev Environment") + val envId = System.getenv(CawsConstants.CAWS_ENV_ID_VAR) ?: error("envId env var null") + val org = System.getenv(CawsConstants.CAWS_ENV_ORG_NAME_VAR) ?: error("space env var null") + val projectName = System.getenv(CawsConstants.CAWS_ENV_PROJECT_NAME_VAR) ?: error("project env var null") + val client = connection.awsClient() + val coroutineScope = projectCoroutineScope(project) + coroutineScope.launch(getCoroutineBgContext()) { + val initialEnv = client.getDevEnvironment { + it.id(envId) + it.spaceName(org) + it.projectName(projectName) + } + val inactivityTimeout = initialEnv.inactivityTimeoutMinutes() + if (inactivityTimeout == 0) { + LOG.info { "Dev environment inactivity timeout is 0, not monitoring" } + return@launch + } + val inactivityTimeoutInSeconds = inactivityTimeout * 60 + + // TODO: Re-enable when Gateway APIs are available in 2025.3 + // val jbActivityStatus = GatewayConnectionUtil.getInstance().getSecondsSinceLastControllerActivity() + val jbActivityStatus = 0L // Temporary fallback + notifyBackendOfActivity((getActivityTime(jbActivityStatus).toString())) + var secondsSinceLastControllerActivity = jbActivityStatus + + while (true) { + val response = checkHeartbeat(secondsSinceLastControllerActivity, inactivityTimeoutInSeconds, project) + if (response.first) return@launch + delay(30000) + secondsSinceLastControllerActivity = response.second + } + } + } + + // This function returns a Pair The first value is a boolean indicating if the API returned the last recorded activity. + // If inactivity tracking is disabled or if the value returned by the API is unparseable, the heartbeat is not sent + // The second value indicates the seconds since last activity as recorded by JB in the most recent run + fun checkHeartbeat( + secondsSinceLastControllerActivity: Long, + inactivityTimeoutInSeconds: Int, + project: Project, + ): Pair { + val lastActivityTime = getJbRecordedActivity() + + if (lastActivityTime < secondsSinceLastControllerActivity) { + // update the API in case of any activity + notifyBackendOfActivity((getActivityTime(lastActivityTime).toString())) + } + + val lastRecordedActivityTime = getLastRecordedApiActivity() + if (lastRecordedActivityTime == null) { + LOG.error { "Couldn't retrieve last recorded activity from API" } + return Pair(true, lastActivityTime) + } + val durationRecordedSinceLastActivity = Instant.now().toEpochMilli().minus(lastRecordedActivityTime.toLong()) + val secondsRecordedSinceLastActivity = durationRecordedSinceLastActivity / 1000 + + if (secondsRecordedSinceLastActivity >= (inactivityTimeoutInSeconds - 300)) { + try { + val inactivityDurationInMinutes = secondsRecordedSinceLastActivity / 60 + val ans = runBlocking { + val continueWorking = withContext(getCoroutineUiContext()) { + return@withContext MessageDialogBuilder.okCancel( + message("caws.devenv.continue.working.after.timeout.title"), + message("caws.devenv.continue.working.after.timeout", inactivityDurationInMinutes) + ).ask(project) + } + return@runBlocking continueWorking + } + + if (ans) { + notifyBackendOfActivity(getActivityTime().toString()) + } + } catch (e: Exception) { + val preMessage = "Error while checking if Dev Environment should continue working" + LOG.error(e) { preMessage } + notifyError(preMessage, e.message.toString()) + } + } + return Pair(false, lastActivityTime) + } + + fun getLastRecordedApiActivity(): String? = CawsEnvironmentClient.getInstance().getActivity()?.timestamp + + // TODO: Re-enable when Gateway APIs are available in 2025.3 + // Original: GatewayConnectionUtil.getInstance().getSecondsSinceLastControllerActivity() + private val fallbackActivityTime = 0L + + fun getJbRecordedActivity(): Long = fallbackActivityTime + + fun notifyBackendOfActivity(timestamp: String = Instant.now().toEpochMilli().toString()) { + val request = UpdateActivityRequest( + timestamp = timestamp + ) + CawsEnvironmentClient.getInstance().putActivityTimestamp(request) + } + + private fun getActivityTime(secondsSinceLastActivity: Long = 0): Long = Instant.now().minus(secondsSinceLastActivity, ChronoUnit.SECONDS).toEpochMilli() +} diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt new file mode 100644 index 00000000000..da61ccc13b7 --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/remoteDev/caws/RebuildDevfileRequiredNotification.kt @@ -0,0 +1,27 @@ +// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.remoteDev.caws + +import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.Metric +import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricType +import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.MetricsStatus +import com.jetbrains.rd.platform.codeWithMe.unattendedHost.metrics.providers.MetricProvider +import software.aws.toolkits.resources.message + +class RebuildDevfileRequiredNotification : MetricProvider { + override val id: String + get() = "devfileRebuildRequired" + + override fun getMetrics(): Map = + if (DevfileWatcher.getInstance().hasDevfileChanged()) { + mapOf(Pair("devfileRebuild", RebuildDevfileMetric)) + } else { + mapOf() + } + + // Adding MetricStatus as Danger instead of Warning, cause Warning is overriden by other notifications provided by the client + object RebuildDevfileMetric : Metric(MetricType.OTHER, MetricsStatus.DANGER, true) { + override fun toString(): String = message("caws.rebuild.workspace.notification") + } +} diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt new file mode 100644 index 00000000000..33ab5f5745e --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/go/GoHelper.kt @@ -0,0 +1,74 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.go + +// TODO: Re-enable when Go plugin APIs are available in 2025.3 +// import com.goide.dlv.DlvDebugProcessUtil +import com.goide.execution.GoRunUtil +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.xdebugger.XDebugProcess +import com.intellij.xdebugger.XDebugProcessStarter +import com.intellij.xdebugger.XDebugSession +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import java.nio.file.Files + +/** + * "Light" ides like Goland do not rely on marking folders as source root, so infer it based on + * the go.mod file. This function is based off of the similar PackageJsonUtil#findUpPackageJson + * + * @throws IllegalStateException If the contentRoot cannot be located + */ +fun inferSourceRoot(project: Project, virtualFile: VirtualFile): VirtualFile? { + val projectFileIndex = ProjectFileIndex.getInstance(project) + val contentRoot = runReadAction { + projectFileIndex.getContentRootForFile(virtualFile) + } + + return contentRoot?.let { root -> + var file = virtualFile.parent + while (file != null) { + if ((file.isDirectory && file.children.any { !it.isDirectory && it.name == "go.mod" })) { + return file + } + // If we go up to the root and it's still not found, stop going up and mark source root as + // not found, since it will fail to build + if (file == root) { + return null + } + file = file.parent + } + return null + } +} + +object GoDebugHelper { + // TODO see https://youtrack.jetbrains.com/issue/GO-10775 for "Debugger disconnected unexpectedly" when the lambda finishes + fun createGoDebugProcess( + @Suppress("UNUSED_PARAMETER") debugHost: String, + @Suppress("UNUSED_PARAMETER") debugPorts: List, + @Suppress("UNUSED_PARAMETER") context: Context, + ): XDebugProcessStarter = object : XDebugProcessStarter() { + override fun start(session: XDebugSession): XDebugProcess { + // TODO: Re-enable when Go plugin APIs are available in 2025.3 + // val process = DlvDebugProcessUtil.createDlvDebugProcess(session, DlvDisconnectOption.KILL, null, true) + throw UnsupportedOperationException("Go debugging temporarily disabled in 2025.3 - Go plugin APIs moved") + } + } + + fun copyDlv(): String { + // This can take a target platform, but that pulls directly from GOOS, so we have to walk back up the file tree + // either way. Goland comes with mac/window/linux dlv since it supports remote debugging, so it is always safe to + // pull the linux one + val dlvFolder = GoRunUtil.getBundledDlv(null)?.parentFile?.parentFile?.resolve("linux") + ?: throw IllegalStateException("Packaged Devle debugger is not found!") + val directory = Files.createTempDirectory("goDebugger") + Files.copy(dlvFolder.resolve("dlv").toPath(), directory.resolve("dlv")) + // Delve that comes packaged with the IDE does not have the executable flag set + directory.resolve("dlv").toFile().setExecutable(true) + return directory.toAbsolutePath().toString() + } +} diff --git a/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt new file mode 100644 index 00000000000..51b36f27f65 --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/src-253+/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsDebugSupport.kt @@ -0,0 +1,128 @@ +// Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.lambda.nodejs + +import com.google.common.collect.BiMap +import com.google.common.collect.HashBiMap +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.execution.ui.ExecutionConsole +import com.intellij.javascript.debugger.LocalFileSystemFileFinder +import com.intellij.javascript.debugger.RemoteDebuggingFileFinder +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.xdebugger.XDebugProcess +import com.intellij.xdebugger.XDebugProcessStarter +import com.intellij.xdebugger.XDebugSession +import com.intellij.xdebugger.evaluation.XDebuggerEditorsProviderBase +import compat.com.intellij.lang.javascript.JavascriptLanguage +import org.jetbrains.io.LocalFileFinder +import software.aws.toolkits.core.lambda.LambdaRuntime +import software.aws.toolkits.jetbrains.services.PathMapping +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.ImageDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.RuntimeDebugSupport +import software.aws.toolkits.jetbrains.services.lambda.execution.sam.SamRunningState +import software.aws.toolkits.jetbrains.utils.execution.steps.Context +import javax.swing.JComponent +import javax.swing.JLabel + +class NodeJsRuntimeDebugSupport : RuntimeDebugSupport { + override suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List, + ): XDebugProcessStarter = NodeJsDebugUtils.createDebugProcess(state, debugHost, debugPorts) +} + +abstract class NodeJsImageDebugSupport : ImageDebugSupport { + override fun supportsPathMappings(): Boolean = true + override val languageId = JavascriptLanguage.id + override suspend fun createDebugProcess( + context: Context, + environment: ExecutionEnvironment, + state: SamRunningState, + debugHost: String, + debugPorts: List, + ): XDebugProcessStarter = NodeJsDebugUtils.createDebugProcess(state, debugHost, debugPorts) + + override fun containerEnvVars(debugPorts: List): Map = mapOf( + "NODE_OPTIONS" to "--inspect-brk=0.0.0.0:${debugPorts.first()} --max-http-header-size 81920" + ) +} + +class NodeJs16ImageDebug : NodeJsImageDebugSupport() { + override val id: String = LambdaRuntime.NODEJS16_X.toString() + override fun displayName() = LambdaRuntime.NODEJS16_X.toString().capitalize() +} + +class NodeJs18ImageDebug : NodeJsImageDebugSupport() { + override val id: String = LambdaRuntime.NODEJS18_X.toString() + override fun displayName() = LambdaRuntime.NODEJS18_X.toString().capitalize() +} + +class NodeJs20ImageDebug : NodeJsImageDebugSupport() { + override val id: String = LambdaRuntime.NODEJS20_X.toString() + override fun displayName() = LambdaRuntime.NODEJS20_X.toString().capitalize() +} + +object NodeJsDebugUtils { + private const val NODE_MODULES = "node_modules" + + // Noop editors provider for disabled NodeJS debugging in 2025.3 + private class NoopXDebuggerEditorsProvider : XDebuggerEditorsProviderBase() { + override fun getFileType(): FileType = PlainTextFileType.INSTANCE + override fun createExpressionCodeFragment(project: Project, text: String, context: PsiElement?, isPhysical: Boolean): PsiFile? = null + } + + fun createDebugProcess( + state: SamRunningState, + @Suppress("UNUSED_PARAMETER") debugHost: String, + @Suppress("UNUSED_PARAMETER") debugPorts: List, + ): XDebugProcessStarter = object : XDebugProcessStarter() { + override fun start(session: XDebugSession): XDebugProcess { + val mappings = createBiMapMappings(state.pathMappings) + + @Suppress("UNUSED_VARIABLE") + val fileFinder = RemoteDebuggingFileFinder(mappings, LocalFileSystemFileFinder()) + + // STUB IMPLEMENTATION: NodeJS debugging temporarily disabled + return object : XDebugProcess(session) { + override fun getEditorsProvider() = NoopXDebuggerEditorsProvider() + override fun doGetProcessHandler() = null + override fun createConsole() = object : ExecutionConsole { + override fun getComponent(): JComponent = JLabel("NodeJS debugging disabled in 2025.3") + override fun getPreferredFocusableComponent(): JComponent? = null + override fun dispose() {} + } + } + } + } + + /** + * Convert [PathMapping] to NodeJs debugger path mapping format. + * + * Docker uses the same project structure for dependencies in the folder node_modules. We map the source code and + * the dependencies in node_modules folder separately as the node_modules might not exist in the local project. + */ + private fun createBiMapMappings(pathMapping: List): BiMap { + val mappings = HashBiMap.create(pathMapping.size) + + listOf(".", NODE_MODULES).forEach { subPath -> + pathMapping.forEach { + val remotePath = FileUtil.toCanonicalPath("${it.remoteRoot}/$subPath") + LocalFileFinder.findFile("${it.localRoot}/$subPath")?.let { localFile -> + mappings.putIfAbsent("file://$remotePath", localFile) + } + } + } + + return mappings + } +} diff --git a/plugins/toolkit/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt b/plugins/toolkit/jetbrains-ultimate/tst-242-252/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt similarity index 100% rename from plugins/toolkit/jetbrains-ultimate/tst/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt rename to plugins/toolkit/jetbrains-ultimate/tst-242-252/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt diff --git a/plugins/toolkit/jetbrains-ultimate/tst-253+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt b/plugins/toolkit/jetbrains-ultimate/tst-253+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt new file mode 100644 index 00000000000..c879a3fe7d2 --- /dev/null +++ b/plugins/toolkit/jetbrains-ultimate/tst-253+/software/aws/toolkits/jetbrains/services/redshift/RedshiftUtilsTest.kt @@ -0,0 +1,43 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.redshift + +import com.intellij.testFramework.HeavyPlatformTestCase +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import software.amazon.awssdk.services.redshift.model.Cluster +import software.aws.toolkits.core.utils.RuleUtils +import software.aws.toolkits.jetbrains.core.MockResourceCacheExtension +import software.aws.toolkits.jetbrains.core.region.getDefaultRegion +import software.aws.toolkits.jetbrains.services.sts.StsResources + +class RedshiftUtilsTest : HeavyPlatformTestCase() { + private val resourceCache = MockResourceCacheExtension() + private lateinit var clusterId: String + private lateinit var accountId: String + private lateinit var mockCluster: Cluster + + override fun setUp() { + super.setUp() + clusterId = RuleUtils.randomName() + accountId = RuleUtils.randomName() + mockCluster = mock { + on { clusterIdentifier() } doReturn clusterId + } + } + + fun testAccountIdArn() { + val region = getDefaultRegion() + resourceCache.addEntry(project, StsResources.ACCOUNT, accountId) + val arn = project.clusterArn(mockCluster, region) + assertThat(arn).isEqualTo("arn:${region.partitionId}:redshift:${region.id}:$accountId:cluster:$clusterId") + } + + fun testNoAccountIdArn() { + val region = getDefaultRegion() + val arn = project.clusterArn(mockCluster, region) + assertThat(arn).isEqualTo("arn:${region.partitionId}:redshift:${region.id}::cluster:$clusterId") + } +}