diff --git a/build.gradle.kts b/build.gradle.kts index cdfc5e8..1389fc0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.mokk) testImplementation(libs.bundles.toolbox.plugin.api) + testImplementation(libs.coroutines.test) } val extension = ExtensionJson( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 253d2c1..5b83680 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "t toolbox-ui-api = { module = "com.jetbrains.toolbox:ui-api", version.ref = "toolbox-plugin-api" } toolbox-remote-dev-api = { module = "com.jetbrains.toolbox:remote-dev-api", version.ref = "toolbox-plugin-api" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "serialization" } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 98cc1ab..cde3819 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -2,6 +2,7 @@ package com.coder.toolbox import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.feed.IdeFeedManager import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException @@ -96,7 +97,7 @@ class CoderRemoteProvider( providerVisible = false ) ) - private val linkHandler = CoderProtocolHandler(context) + private val linkHandler = CoderProtocolHandler(context, IdeFeedManager(context)) override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") override val environments: MutableStateFlow>> = MutableStateFlow( diff --git a/src/main/kotlin/com/coder/toolbox/feed/FeedModels.kt b/src/main/kotlin/com/coder/toolbox/feed/FeedModels.kt new file mode 100644 index 0000000..eb4bb33 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/feed/FeedModels.kt @@ -0,0 +1,87 @@ +package com.coder.toolbox.feed + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.ToJson + +/** + * Represents a JetBrains IDE product from the feed API. + * + * The API returns an array of products, each with a code and a list of releases. + */ +@JsonClass(generateAdapter = true) +data class IdeProduct( + @Json(name = "code") val code: String, + @Json(name = "intellijProductCode") val intellijProductCode: String, + @Json(name = "name") val name: String, + @Json(name = "releases") val releases: List = emptyList() +) + +/** + * Represents an individual release of a JetBrains IDE product. + */ +@JsonClass(generateAdapter = true) +data class IdeRelease( + @Json(name = "build") val build: String, + @Json(name = "version") val version: String, + @Json(name = "type") val type: IdeType, + @Json(name = "date") val date: String +) + +/** + * Type of IDE release: release or EAP (Early Access Program) + */ +enum class IdeType { + RELEASE, + EAP, + UNSUPPORTED; + + val value: String + get() = when (this) { + RELEASE -> "release" + EAP -> "eap" + UNSUPPORTED -> "unsupported" + } +} + +class IdeTypeAdapter { + @FromJson + fun fromJson(type: String): IdeType { + return when (type.lowercase()) { + "release" -> IdeType.RELEASE + "eap" -> IdeType.EAP + else -> IdeType.UNSUPPORTED + } + } + + @ToJson + fun toJson(type: IdeType): String = type.value +} + +/** + * Simplified representation of an IDE for use in the plugin. + * + * Contains the essential information: product code, build number, version, and type. + */ +@JsonClass(generateAdapter = true) +data class Ide( + val code: String, + val build: String, + val version: String, + val type: IdeType +) { + companion object { + /** + * Create an Ide from an IdeProduct and IdeRelease. + */ + fun from(product: IdeProduct, release: IdeRelease): Ide { + return Ide( + code = product.intellijProductCode, + build = release.build, + version = release.version, + type = release.type + ) + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt new file mode 100644 index 0000000..6e54503 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt @@ -0,0 +1,235 @@ +package com.coder.toolbox.feed + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.plugin.PluginManager +import com.coder.toolbox.sdk.CoderHttpClientBuilder +import com.coder.toolbox.sdk.interceptors.Interceptors +import com.coder.toolbox.util.OS +import com.coder.toolbox.util.ReloadableTlsContext +import com.coder.toolbox.util.getOS +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.readText + +/** + * Manages the caching and loading of JetBrains IDE product feeds. + * + * This manager handles fetching IDE information from JetBrains data services, + * caching the results locally, and supporting offline mode. + * + * Cache files are stored in platform-specific locations: + * - macOS: ~/Library/Application Support/JetBrains/Toolbox/plugins/com.coder.toolbox/ + * - Linux: ~/.local/share/JetBrains/Toolbox/plugins/com.coder.toolbox/ + * - Windows: %LOCALAPPDATA%/JetBrains/Toolbox/plugins/com.coder.toolbox/ + */ +class IdeFeedManager( + private val context: CoderToolboxContext, + feedService: JetBrainsFeedService? = null +) { + private val moshi = Moshi.Builder() + .add(IdeTypeAdapter()) + .build() + + // Lazy initialization of the feed service + private val feedService: JetBrainsFeedService by lazy { + if (feedService != null) return@lazy feedService + + val interceptors = buildList { + add((Interceptors.userAgent(PluginManager.pluginInfo.version))) + add(Interceptors.logging(context)) + } + val okHttpClient = CoderHttpClientBuilder.build( + context, + interceptors, + ReloadableTlsContext(context.settingsStore.readOnly().tls) + ) + + val retrofit = Retrofit.Builder() + .baseUrl("https://data.services.jetbrains.com/") + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + val feedApi = retrofit.create(JetBrainsFeedApi::class.java) + JetBrainsFeedService(context, feedApi) + } + + private var cachedIdes: List? = null + + /** + * Lazily load the IDE list. + * + * This method will only execute once. Subsequent calls will return the cached result. + * + * If offline mode is enabled (via -Doffline=true), this will load from local cache files. + * Otherwise, it will fetch from the remote feeds and save to local cache. + * + * @return List of IDE objects from both release and EAP feeds + */ + suspend fun loadIdes(): List { + // Return cached value if already loaded + cachedIdes?.let { return it } + + val isOffline = isOfflineMode() + context.logger.info("Loading IDEs in ${if (isOffline) "offline" else "online"} mode") + + val ides = if (isOffline) { + loadIdesOffline() + } else { + loadIdesOnline() + } + + cachedIdes = ides + return ides + } + + /** + * Load IDEs from local cache files in offline mode. + */ + private suspend fun loadIdesOffline(): List = withContext(Dispatchers.IO) { + context.logger.info("Loading IDEs from local cache files") + + val releaseIdes = loadFeedFromFile(getReleaseCachePath()) + val eapIdes = loadFeedFromFile(getEapCachePath()) + + val allIdes = releaseIdes + eapIdes + context.logger.info("Loaded ${allIdes.size} IDEs from cache (${releaseIdes.size} release, ${eapIdes.size} EAP)") + + allIdes + } + + /** + * Fetch IDEs from remote feeds and cache them locally. + */ + private suspend fun loadIdesOnline(): List { + context.logger.info("Fetching IDEs from remote feeds") + + // Fetch from both feeds + val releaseIdes = try { + feedService.fetchReleaseFeed() + } catch (e: Exception) { + context.logger.warn(e, "Failed to fetch release feed") + emptyList() + } + + val eapIdes = try { + feedService.fetchEapFeed() + } catch (e: Exception) { + context.logger.warn(e, "Failed to fetch EAP feed") + emptyList() + } + + val allIdes = releaseIdes + eapIdes + context.logger.info("Fetched ${allIdes.size} IDEs from remote (${releaseIdes.size} release, ${eapIdes.size} EAP)") + + return allIdes + } + + /** + * Get the platform-specific cache directory path. + */ + private fun getCacheDirectory(): Path { + val os = getOS() + val userHome = System.getProperty("user.home") + + val basePath = when (os) { + OS.MAC -> Path.of(userHome, "Library", "Application Support") + OS.LINUX -> Path.of(userHome, ".local", "share") + OS.WINDOWS -> { + val localAppData = System.getenv("LOCALAPPDATA") + ?: Path.of(userHome, "AppData", "Local").toString() + Path.of(localAppData) + } + + null -> { + context.logger.warn("Unable to determine OS, using home directory for cache") + Path.of(userHome, ".cache") + } + } + + return basePath.resolve("JetBrains/Toolbox/plugins/com.coder.toolbox") + } + + /** + * Get the path for the release feed cache file. + */ + private fun getReleaseCachePath(): Path { + return getCacheDirectory().resolve(RELEASE_CACHE_FILE) + } + + /** + * Get the path for the EAP feed cache file. + */ + private fun getEapCachePath(): Path { + return getCacheDirectory().resolve(EAP_CACHE_FILE) + } + + /** + * Load a list of IDEs from a JSON file. + * + * @return List of IDEs, or empty list if the file doesn't exist or can't be read + */ + private suspend fun loadFeedFromFile(path: Path): List = withContext(Dispatchers.IO) { + try { + if (!path.exists()) { + context.logger.info("Cache file does not exist: $path") + return@withContext emptyList() + } + + val json = path.readText() + val listType = Types.newParameterizedType(List::class.java, Ide::class.java) + val adapter = moshi.adapter>(listType) + val ides = adapter.fromJson(json) ?: emptyList() + + context.logger.info("Loaded ${ides.size} IDEs from $path") + ides + } catch (e: Exception) { + context.logger.warn(e, "Failed to load feed from $path") + emptyList() + } + } + + /** + * Check if offline mode is enabled via the -Doffline=true system property. + */ + private fun isOfflineMode(): Boolean { + return System.getProperty(OFFLINE_PROPERTY)?.toBoolean() == true + } + + /** + * Find the best matching IDE based on the provided query criteria. + * + * This method filters the loaded IDEs by product code and type, optionally + * filtering by available builds, then returns the IDE with the highest build. + * + * @param productCode The IntelliJ product code (e.g., "RR" for RustRover) + * @param type The type of IDE release (RELEASE or EAP) + * @param availableBuilds List of acceptable builds to filter by + * @return The IDE with the highest build matching the criteria, or null if no match found + */ + suspend fun findBestMatch( + productCode: String, + type: IdeType, + availableBuilds: List + ): Ide? { + val ides = loadIdes() + + return ides + .filter { it.code == productCode } + .filter { it.type == type } + .filter { it.build in availableBuilds } + .maxByOrNull { it.build } + } + + companion object { + private const val RELEASE_CACHE_FILE = "release.json" + private const val EAP_CACHE_FILE = "eap.json" + private const val OFFLINE_PROPERTY = "offline" + } +} diff --git a/src/main/kotlin/com/coder/toolbox/feed/IdeQuery.kt b/src/main/kotlin/com/coder/toolbox/feed/IdeQuery.kt new file mode 100644 index 0000000..836a512 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/feed/IdeQuery.kt @@ -0,0 +1,24 @@ +package com.coder.toolbox.feed + +/** + * Query object for finding the best matching IDE from loaded feeds. + * + * This encapsulates the filtering criteria for IDE selection, including + * product code, type (release/eap), and optionally available versions. + */ +data class IdeQuery( + /** + * The IntelliJ product code (e.g., "RR" for RustRover, "IU" for IntelliJ IDEA Ultimate) + */ + val productCode: String, + + /** + * The type of IDE release to filter for + */ + val type: IdeType, + + /** + * List of available builds to install. + */ + val availableBuilds: List +) diff --git a/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedApi.kt b/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedApi.kt new file mode 100644 index 0000000..2c28ced --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedApi.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox.feed + +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Url + +/** + * Retrofit API for fetching JetBrains IDE product feeds. + * + * Fetches product information from data.services.jetbrains.com for both + * release and EAP (Early Access Program) builds. + */ +interface JetBrainsFeedApi { + /** + * Fetch the product feed from the specified URL. + * + * @param url The full URL to fetch (e.g., https://data.services.jetbrains.com/products?type=release) + * @return Response containing a list of IDE products + */ + @GET + suspend fun fetchFeed(@Url url: String): Response> +} diff --git a/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt b/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt new file mode 100644 index 0000000..eb3ee0d --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt @@ -0,0 +1,78 @@ +package com.coder.toolbox.feed + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.ex.ResponseException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection.HTTP_OK + +/** + * Service for fetching JetBrains IDE product feeds. + * + * This service fetches IDE product information from JetBrains data services, + * parsing the response and extracting relevant IDE information. + */ +class JetBrainsFeedService( + private val context: CoderToolboxContext, + private val feedApi: JetBrainsFeedApi +) { + companion object { + private const val RELEASE_FEED_URL = "https://data.services.jetbrains.com/products?type=release" + private const val EAP_FEED_URL = "https://data.services.jetbrains.com/products?type=eap" + } + + /** + * Fetch the release feed and return a list of IDEs. + * + * @return List of IDE objects from the release feed + * @throws ResponseException if the request fails + */ + suspend fun fetchReleaseFeed(): List { + return fetchFeed(RELEASE_FEED_URL, "release") + } + + /** + * Fetch the EAP (Early Access Program) feed and return a list of IDEs. + * + * @return List of IDE objects from the EAP feed + * @throws ResponseException if the request fails + */ + suspend fun fetchEapFeed(): List { + return fetchFeed(EAP_FEED_URL, "eap") + } + + /** + * Fetch a feed from the specified URL and parse it into a list of IDEs. + * + * @param url The URL to fetch from + * @param feedType The type of feed (for logging and error messages) + * @return List of IDE objects + * @throws ResponseException if the request fails + */ + private suspend fun fetchFeed(url: String, feedType: String): List = withContext(Dispatchers.IO) { + context.logger.info("Fetching $feedType feed from $url") + + val response = feedApi.fetchFeed(url) + + when (response.code()) { + HTTP_OK -> { + val products = response.body() ?: emptyList() + context.logger.info("Successfully fetched ${products.size} products from $feedType feed") + + // Flatten all products and their releases into a list of Ide objects + products.flatMap { product -> + product.releases.map { release -> + Ide.from(product, release) + } + } + } + + else -> { + throw ResponseException( + "Failed to fetch $feedType feed from $url", + response.code() + ) + } + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index ae6d13a..0590244 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -2,6 +2,8 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.feed.IdeFeedManager +import com.coder.toolbox.feed.IdeType import com.coder.toolbox.models.WorkspaceAndAgentStatus import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace @@ -26,6 +28,7 @@ private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, + private val ideFeedManager: IdeFeedManager, ) { private val settings = context.settingsStore.readOnly() @@ -234,72 +237,149 @@ open class CoderProtocolHandler( private fun launchIde( environmentId: String, productCode: String, - buildNumber: String, + buildNumberHint: String, projectFolder: String? ) { context.cs.launch(CoroutineName("Launch Remote IDE")) { - val selectedIde = selectAndInstallRemoteIde(productCode, buildNumber, environmentId) ?: return@launch - context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") + val selectedIde = selectAndInstallRemoteIde(productCode, buildNumberHint, environmentId) ?: return@launch + context.logger.info("Selected IDE $selectedIde for $productCode with hint $buildNumberHint") + + // Ensure JBClient is prepared (installed/downloaded locally) installJBClient(selectedIde, environmentId).join() + + // Launch launchJBClient(selectedIde, environmentId, projectFolder) } } private suspend fun selectAndInstallRemoteIde( productCode: String, - buildNumber: String, + buildNumberHint: String, environmentId: String ): String? { - val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + val selectedIde = resolveIdeIdentifier(environmentId, productCode, buildNumberHint) ?: return null + val installedIdeVersions = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) - var selectedIde = "$productCode-$buildNumber" - if (installedIdes.firstOrNull { it.contains(buildNumber) } != null) { + if (installedIdeVersions.contains(selectedIde)) { context.logger.info("$selectedIde is already installed on $environmentId") return selectedIde } - selectedIde = resolveAvailableIde(environmentId, productCode, buildNumber) ?: return null - - // needed otherwise TBX will install it again - if (!installedIdes.contains(selectedIde)) { - context.logger.info("Installing $selectedIde on $environmentId...") - context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) - if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { - context.logger.info("Successfully installed $selectedIde on $environmentId...") - return selectedIde - } else { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr("$selectedIde could not be installed"), - context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), - context.i18n.ptrl("OK") - ) - return null - } - } else { - context.logger.info("$selectedIde is already present on $environmentId...") + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIde on $environmentId.") return selectedIde + } else { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$selectedIde could not be installed"), + context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), + context.i18n.ptrl("OK") + ) + return null } } - private suspend fun resolveAvailableIde(environmentId: String, productCode: String, buildNumber: String): String? { - val availableVersions = context - .remoteIdeOrchestrator - .getAvailableRemoteTools(environmentId, productCode) + /** + * Resolves the full IDE identifier (e.g., "RR-241.14494.240") based on the build hint. + * Supports: latest_eap, latest_release, latest_installed, or specific build number. + */ + internal suspend fun resolveIdeIdentifier( + environmentId: String, + productCode: String, + buildNumberHint: String + ): String? { + val availableBuilds = context.remoteIdeOrchestrator.getAvailableRemoteTools(environmentId, productCode) + .map { it.substringAfter("$productCode-") } + val installed = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + .map { it.substringAfter("$productCode-") } + + when (buildNumberHint) { + "latest_eap" -> { + val bestEap = ideFeedManager.findBestMatch( + productCode, + IdeType.EAP, + availableBuilds + ) - if (availableVersions.isEmpty()) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") - return null - } + return if (bestEap != null) { + bestEap.build + } else { + if (availableBuilds.isEmpty()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't launch EAP for $productCode because no version is available on $environmentId" + ) + return null + } + // Fallback to max available + val fallback = availableBuilds.maxBy { it } + context.logger.info("No EAP found for $productCode, falling back to latest available: $fallback") + fallback + } + } - val buildNumberIsNotAvailable = availableVersions.firstOrNull { it.contains(buildNumber) } == null - if (buildNumberIsNotAvailable) { - val selectedIde = availableVersions.maxOf { it } - context.logger.info("$productCode-$buildNumber is not available, we've selected the latest $selectedIde") - return selectedIde + "latest_release" -> { + val bestRelease = ideFeedManager.findBestMatch( + productCode, + IdeType.RELEASE, + availableBuilds.map { it.substringAfter("$productCode-") }) + + return if (bestRelease != null) { + bestRelease.build + } else { + if (availableBuilds.isEmpty()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't launch Release for $productCode because no version is available on $environmentId" + ) + return null + } + val fallback = availableBuilds.maxBy { it } + context.logger.info("No Release found for $productCode, falling back to latest available: $fallback") + fallback + } + } + + "latest_installed" -> { + if (installed.isNotEmpty()) { + return installed.maxBy { it } + } + if (availableBuilds.isEmpty()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't launch latest installed version for $productCode because there is no version installed nor available for install on $environmentId" + ) + return null + } + // Fallback to latest available if valid + val fallback = availableBuilds.maxBy { it } + context.logger.info("No installed IDE found, falling back to latest available: $fallback") + return fallback + } + + else -> { + // Specific build number. First check it in the installed list of builds + // then in the available list of builds + val installedMatch = installed.firstOrNull { it.contains(buildNumberHint) } + if (installedMatch != null) { + return installedMatch + } + val availableMatch = availableBuilds.filter { it.contains(buildNumberHint) }.maxByOrNull { it } + if (availableMatch != null) { + return availableMatch + } else { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't launch $productCode-$buildNumberHint because there is no matching version installed nor available for install on $environmentId" + ) + return null + } + + } } - return "$productCode-$buildNumber" } private fun installJBClient(selectedIde: String, environmentId: String): Job = @@ -338,7 +418,9 @@ open class CoderProtocolHandler( withTimeout(waitTime.toJavaDuration()) { while (!isInstalled) { delay(5.seconds) - isInstalled = getInstalledRemoteTools(environmentId, ideHint).isNotEmpty() + val installed = getInstalledRemoteTools(environmentId, ideHint) // Hint matching + // Check if *specific* IDE is installed now + isInstalled = installed.contains(ideHint) || installed.any { it.contains(ideHint) } } } return true diff --git a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt new file mode 100644 index 0000000..83b1d10 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt @@ -0,0 +1,271 @@ +package com.coder.toolbox.feed + +import com.coder.toolbox.CoderToolboxContext +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class IdeFeedManagerTest { + private lateinit var context: CoderToolboxContext + private lateinit var logger: Logger + private lateinit var feedService: JetBrainsFeedService + private lateinit var ideFeedManager: IdeFeedManager + + private val originalUserHome = System.getProperty("user.home") + + // Diversified dataset with multiple products and types + private val releaseIdes = listOf( + Ide("RR", "241.1", "2024.1", IdeType.RELEASE), + Ide("RR", "242.1", "2024.2", IdeType.RELEASE), + Ide("RR", "242.2", "2024.2.1", IdeType.RELEASE), + Ide("IU", "241.1", "2024.1", IdeType.RELEASE), // IntelliJ Ultimate + Ide("IU", "242.1", "2024.2", IdeType.RELEASE), + Ide("IC", "241.1", "2024.1", IdeType.RELEASE), // IntelliJ Community + Ide("GO", "241.1", "2024.1", IdeType.RELEASE) // GoLand + ) + + private val eapIdes = listOf( + Ide("RR", "243.1", "2024.3", IdeType.EAP), + Ide("RR", "242.1", "2024.2-EAP", IdeType.EAP), // Same build number as release, but EAP type + Ide("IU", "243.1", "2024.3", IdeType.EAP), + Ide("GO", "243.1", "2024.3", IdeType.EAP) + ) + + @BeforeEach + fun setUp(@TempDir tempDir: Path) { + // Divert cache files to a temporary directory to avoid overwriting real data + System.setProperty("user.home", tempDir.toAbsolutePath().toString()) + + context = mockk() + logger = mockk(relaxed = true) + every { context.logger } returns logger + + feedService = mockk() + ideFeedManager = IdeFeedManager(context, feedService) + } + + @AfterEach + fun tearDown() { + if (originalUserHome != null) { + System.setProperty("user.home", originalUserHome) + } else { + System.clearProperty("user.home") + } + } + + @Test + fun `given a list of available release builds when findBestMatch is called with a valid product code and type then it returns the matching IDE with highest build`() = + runTest { + // Given + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When + val result = ideFeedManager.findBestMatch("RR", IdeType.RELEASE, listOf("241.1", "242.1")) + + // Then + assertNotNull(result) + assertEquals("242.1", result?.build) + assertEquals("RR", result?.code) + assertEquals(IdeType.RELEASE, result?.type) + } + + @Test + fun `given available builds that do not intersect with loaded IDEs when findBestMatch is called then it returns null`() = + runTest { + // Given + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When + val result = ideFeedManager.findBestMatch( + "RR", + IdeType.RELEASE, + listOf("253.1") // Build present in neither list + ) + + // Then + assertNull(result) + } + + @Test + fun `given multiple matching builds when findBestMatch is called then it returns the highest build`() = runTest { + // Given + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When + val result = ideFeedManager.findBestMatch( + "RR", + IdeType.RELEASE, + listOf("240.1", "241.1", "242.1", "242.2", "243.1") + ) + + // Then + assertEquals("242.2", result?.build) + assertEquals(IdeType.RELEASE, result?.type) + } + + @Test + fun `given eap type requested when findBestMatch is called then it filters for eap ides`() = runTest { + // Given + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When + val result = ideFeedManager.findBestMatch( + "RR", + IdeType.EAP, + listOf("243.1") + ) + + // Then + assertNotNull(result) + assertEquals(IdeType.EAP, result?.type) + assertEquals("243.1", result?.build) + assertEquals("RR", result?.code) + } + + @Test + fun `given mixed release and eap IDEs when matching release then it ignores eaps even if they have higher builds`() = + runTest { + // Given + // In our dataset: RR release has 241.1, 242.1, 242.2. RR eap has 242.1, 243.1. + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When requesting RELEASE 243.1 (which exists as EAP) or 242.1 (exists as both) + // We only ask for 242.1 and 243.1. 243.1 is EAP only. 242.1 is both. + val result = ideFeedManager.findBestMatch( + "RR", + IdeType.RELEASE, + listOf("242.1", "243.1") + ) + + // Then + assertNotNull(result) + assertEquals("242.1", result?.build) + assertEquals(IdeType.RELEASE, result?.type) + } + + @Test + fun `given mixed release and eap IDEs when matching eap then it ignores releases even if they have higher builds`() = + runTest { + // Given + // In our dataset: RR release has 242.2 (higher than 242.1). RR eap has 242.1. + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When we ask for EAP, and list includes 242.2 (release only) and 242.1 (EAP and Release) + val result = ideFeedManager.findBestMatch( + "RR", + IdeType.EAP, + listOf("242.1", "242.2") + ) + + // Then + assertNotNull(result) + assertEquals("242.1", result?.build) + assertEquals(IdeType.EAP, result?.type) + } + + @Test + fun `given empty available builds list when finding best match then it returns null`() = runTest { + // Given + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When + val result = ideFeedManager.findBestMatch( + "RR", + IdeType.RELEASE, + emptyList() // Empty constraints + ) + + // Then + assertNull(result) + } + + @Test + fun `given loaded ides do not match product code when finding best match then it returns null`() = runTest { + // Given + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When asking for a non-existent product code "XX" + val result = ideFeedManager.findBestMatch( + "XX", + IdeType.RELEASE, + listOf("241.1") + ) + + // Then + assertNull(result) + } + + @Test + fun `given available builds contains values not in loaded ides when finding best match then it only considers the highest build that intersects the two lists`() = + runTest { + // Given + coEvery { feedService.fetchReleaseFeed() } returns releaseIdes + coEvery { feedService.fetchEapFeed() } returns eapIdes + + // When: requesting 241.1 (exists) and 999.9 (does not exist) for RR Release + val result = ideFeedManager.findBestMatch( + "RR", + IdeType.RELEASE, + listOf("231.1", "241.1", "999.9") + ) + + // Then + assertNotNull(result) + assertEquals("241.1", result?.build) + } + + @Test + fun `given network error when loading ides then it handles exception gracefully and returns empty list or cached data`() = + runTest { + // Given + coEvery { feedService.fetchReleaseFeed() } throws RuntimeException("Network error") + coEvery { feedService.fetchEapFeed() } returns emptyList() + + // When + val result = ideFeedManager.loadIdes() + + // Then + assertEquals(0, result.size) + } + + @Test + fun `given feed containing unknown types when findBestMatch is called then it ignores unsupported types`() = + runTest { + // Given + val unknownTypeIde = Ide("RR", "245.1", "2024.5", IdeType.UNSUPPORTED) + val validIde = Ide("RR", "241.1", "2024.1", IdeType.RELEASE) + + coEvery { feedService.fetchReleaseFeed() } returns listOf(unknownTypeIde, validIde) + coEvery { feedService.fetchEapFeed() } returns emptyList() + + // When + val result = ideFeedManager.findBestMatch( + "RR", + IdeType.RELEASE, + listOf("241.1", "245.1") + ) + + // Then + assertNotNull(result) + assertEquals("241.1", result?.build) // Should match valid IDE, ignoring the unsupported one + assertEquals(IdeType.RELEASE, result?.type) + } +} diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 326fce0..16fa41f 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -1,227 +1,172 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.sdk.DataGen -import com.coder.toolbox.settings.Environment -import com.coder.toolbox.store.CoderSecretsStore -import com.coder.toolbox.store.CoderSettingsStore -import com.jetbrains.toolbox.api.core.diagnostics.Logger -import com.jetbrains.toolbox.api.core.os.LocalDesktopManager -import com.jetbrains.toolbox.api.localization.LocalizableStringFactory -import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.coder.toolbox.feed.Ide +import com.coder.toolbox.feed.IdeFeedManager +import com.coder.toolbox.feed.IdeType +import com.coder.toolbox.feed.JetBrainsFeedService import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper -import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings -import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette -import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager -import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.runBlocking -import java.util.UUID -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -internal class CoderProtocolHandlerTest { - - private companion object { - val AGENT_RIKER = AgentTestData(name = "Riker", id = "9a920eee-47fb-4571-9501-e4b3120c12f2") - val AGENT_BILL = AgentTestData(name = "Bill", id = "fb3daea4-da6b-424d-84c7-36b90574cfef") - val AGENT_BOB = AgentTestData(name = "Bob", id = "b0e4c54d-9ba9-4413-8512-11ca1e826a24") - - val ALL_AGENTS = mapOf( - AGENT_BOB.name to AGENT_BOB.id, - AGENT_BILL.name to AGENT_BILL.id, - AGENT_RIKER.name to AGENT_RIKER.id - ) - - val SINGLE_AGENT = mapOf(AGENT_BOB.name to AGENT_BOB.id) +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CoderProtocolHandlerTest { + + private lateinit var context: CoderToolboxContext + private lateinit var feedService: JetBrainsFeedService + private lateinit var ideFeedManager: IdeFeedManager + private lateinit var handler: CoderProtocolHandler + private lateinit var remoteToolsHelper: RemoteToolsHelper + + // Test Coroutine Scope + private val dispatcher = StandardTestDispatcher() + + @BeforeEach + fun setUp() { + context = mockk(relaxed = true) + feedService = mockk(relaxed = true) + ideFeedManager = IdeFeedManager(context, feedService) + remoteToolsHelper = mockk(relaxed = true) + + every { context.cs } returns CoroutineScope(dispatcher) + every { context.remoteIdeOrchestrator } returns remoteToolsHelper + + handler = CoderProtocolHandler(context, ideFeedManager) } - private val context = CoderToolboxContext( - mockk(relaxed = true), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(relaxed = true), - mockk(relaxed = true), - CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), - mockk(), - mockk() - ) - - private val protocolHandler = CoderProtocolHandler( - context - ) + @Test + fun `given empty available tools when resolving latest eap then returns null`() = runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns emptyList() + + assertNull(handler.resolveIdeIdentifier("env-1", "RR", "latest_eap")) + } @Test - fun `given a workspace with multiple agents when getMatchingAgent is called with a valid agent name then it correctly resolves resolves an agent`() { - val ws = DataGen.workspace("ws", agents = ALL_AGENTS) - - val testCases = listOf( - AgentMatchTestCase( - "resolves agent with name Riker", - mapOf("agent_name" to AGENT_RIKER.name), - AGENT_RIKER.uuid - ), - AgentMatchTestCase( - "resolves agent with name Bill", - mapOf("agent_name" to AGENT_BILL.name), - AGENT_BILL.uuid - ), - AgentMatchTestCase( - "resolves agent with name Bob", - mapOf("agent_name" to AGENT_BOB.name), - AGENT_BOB.uuid - ) - ) - - runBlocking { - testCases.forEach { testCase -> - assertEquals( - testCase.expectedAgentId, - protocolHandler.getMatchingAgent(testCase.params, ws)?.id, - "Failed: ${testCase.description}" - ) - } + fun `given no matching eap in feed when resolving latest eap then falls back to max available tool`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-241.1", "RR-240.1") + // Feed returns empty or irrelevant EAPs + coEvery { feedService.fetchEapFeed() } returns emptyList() + + assertEquals("241.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_eap")) } - } @Test - fun `given a workspace with multiple agents when getMatchingAgent is called with invalid agent names then no agent is resolved`() { - val ws = DataGen.workspace("ws", agents = ALL_AGENTS) - - val testCases = listOf( - AgentNullResultTestCase( - "empty parameters (i.e. no agent name) does not return any agent", - emptyMap() - ), - AgentNullResultTestCase( - "empty agent_name does not return any agent", - mapOf("agent_name" to "") - ), - AgentNullResultTestCase( - "null agent_name does not return any agent", - mapOf("agent_name" to null) - ), - AgentNullResultTestCase( - "non-existent agent does not return any agent", - mapOf("agent_name" to "agent_name_homer") - ), - AgentNullResultTestCase( - "UUID instead of name does not return any agent", - mapOf("agent_name" to "not-an-agent-name") + fun `given available tools intersects with eap feed when resolving latest eap then returns matched version`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-243.1", "RR-241.1") + coEvery { feedService.fetchEapFeed() } returns listOf( + Ide("RR", "252.1", "2025.2", IdeType.EAP), + Ide("RR", "251.1", "2025.1", IdeType.RELEASE), + Ide("RR", "243.1", "2024.3", IdeType.EAP) ) - ) - - runBlocking { - testCases.forEach { testCase -> - assertNull( - protocolHandler.getMatchingAgent(testCase.params, ws)?.id, - "Failed: ${testCase.description}" - ) - } + + assertEquals("243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_eap")) } + + @Test + fun `given empty available tools when resolving latest release then returns null`() = runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns emptyList() + + assertNull(handler.resolveIdeIdentifier("env-1", "RR", "latest_release")) } @Test - fun `given a workspace with a single agent when getMatchingAgent is called with an empty agent name then the default agent is resolved`() { - val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) - - val testCases = listOf( - AgentMatchTestCase( - "empty parameters (i.e. no agent name) auto-selects the one and only agent available", - emptyMap(), - AGENT_BOB.uuid - ), - AgentMatchTestCase( - "empty agent_name auto-selects the one and only agent available", - mapOf("agent_name" to ""), - AGENT_BOB.uuid - ), - AgentMatchTestCase( - "null agent_name auto-selects the one and only agent available", - mapOf("agent_name" to null), - AGENT_BOB.uuid - ) - ) - - runBlocking { - testCases.forEach { testCase -> - assertEquals( - testCase.expectedAgentId, - protocolHandler.getMatchingAgent(testCase.params, ws)?.id, - "Failed: ${testCase.description}" - ) - } + fun `given no matching release in feed when resolving latest release then falls back to max available tool`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-243.1", "RR-242.1") + coEvery { feedService.fetchReleaseFeed() } returns emptyList() + + assertEquals("243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_release")) } - } @Test - fun `given a workspace with a single agent when getMatchingAgent is called with an invalid agent name then no agent is resolved`() { - val ws = DataGen.workspace("ws", agents = SINGLE_AGENT) - - val testCase = AgentNullResultTestCase( - "non-matching agent_name with single agent", - mapOf("agent_name" to "agent_name_garfield") - ) - - runBlocking { - assertNull( - protocolHandler.getMatchingAgent(testCase.params, ws), - "Failed: ${testCase.description}" + fun `given available tools intersects with release feed when resolving latest release then returns matched version`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-242.1", "RR-241.1") + coEvery { feedService.fetchReleaseFeed() } returns listOf( + Ide("RR", "251.1", "2025.1", IdeType.RELEASE), + Ide("RR", "243.1", "2024.3", IdeType.RELEASE), + Ide("RR", "242.1", "2024.2", IdeType.RELEASE) ) + + assertEquals("242.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_release")) } - } @Test - fun `given a workspace with no agent when getMatchingAgent is called then no agent is resolved`() { - val ws = DataGen.workspace("ws") - - val testCases = listOf( - AgentNullResultTestCase( - "empty parameters (i.e. no agent name) does not return any agent", - emptyMap() - ), - AgentNullResultTestCase( - "empty agent_name does not return any agent", - mapOf("agent_name" to "") - ), - AgentNullResultTestCase( - "null agent_name does not return any agent", - mapOf("agent_name" to null) - ), - AgentNullResultTestCase( - "valid agent_name does not return any agent", - mapOf("agent_name" to AGENT_RIKER.name) - ) - ) - - runBlocking { - testCases.forEach { testCase -> - assertNull( - protocolHandler.getMatchingAgent(testCase.params, ws), - "Failed: ${testCase.description}" - ) - } + fun `given installed tools exist when resolving latest installed then returns max installed version`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns listOf("RR-240.1", "RR-241.1") + + assertEquals("241.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_installed")) } - } - internal data class AgentTestData(val name: String, val id: String) { - val uuid: UUID get() = UUID.fromString(id) - } + @Test + fun `given no installed tools but available tools exist when resolving latest installed then falls back to max available`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns emptyList() + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-243.1", "RR-242.1") + + assertEquals("243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_installed")) + } + + @Test + fun `given no installed and no available tools when resolving latest installed then returns null`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns emptyList() + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns emptyList() + + assertNull(handler.resolveIdeIdentifier("env-1", "RR", "latest_installed")) + } + + @Test + fun `given specific build exists in installed tools but not in available then expect the installed match to be returned`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-241.1", "RR-242.1") + coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns listOf("RR-251.1", "RR-252.1") + + assertEquals("251.1", handler.resolveIdeIdentifier("env-1", "RR", "251.1")) + } + + @Test + fun `given specific build exists in available tools but not in installed then expect the available match to be returned`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-241.1", "RR-242.1") + coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns listOf("RR-251.1", "RR-252.1") + + assertEquals("241.1", handler.resolveIdeIdentifier("env-1", "RR", "241.1")) + } + + @Test + fun `given specific build does not exist in installed tools nor in the available tools then null should be returned`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-241.1", "RR-242.1") + coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns listOf("RR-251.1", "RR-252.1") + + assertNull(handler.resolveIdeIdentifier("env-1", "RR", "221.1")) + } + + @Test + fun `installed build takes precedence over available tools when matching a build number`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf( + "RR-241.1.3", + "RR-242.1" + ) + coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns listOf( + "RR-241.1.2", + "RR-252.1" + ) + + assertEquals("241.1.2", handler.resolveIdeIdentifier("env-1", "RR", "241.1")) + } - internal data class AgentMatchTestCase( - val description: String, - val params: Map, - val expectedAgentId: UUID - ) - internal data class AgentNullResultTestCase( - val description: String, - val params: Map - ) } \ No newline at end of file