From d8c7703a3b5016b0e1dbd3cf381748cbf952038d Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 15 Dec 2025 23:03:57 +0200 Subject: [PATCH 1/7] impl: model for the IDE product This is the IDE model from the https://data.services.jetbrains.com/products? feed. Only `release` and `eap` IDEs will be supported for now. This commit is in preparation for supporting placeholders like `last_release` or `last_eap` in the URI handler. Unfortunately Toolbox does not expose a way to determine if a build like 251.2829.367 is an eap build or an actual release, so we have to look up the metadata in the feed mentioned earlier. --- .../com/coder/toolbox/feed/FeedModels.kt | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/main/kotlin/com/coder/toolbox/feed/FeedModels.kt 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 + ) + } + } +} From 01bcb66bce6af80e8096e98b5183011c6df6c313 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 15 Dec 2025 23:05:40 +0200 Subject: [PATCH 2/7] impl: retrofit service to fetch IDEs from https://data.services.jetbrains.com/products?type=eap and https://data.services.jetbrains.com/products?&type=release --- .../coder/toolbox/feed/JetBrainsFeedApi.kt | 22 ++++++ .../toolbox/feed/JetBrainsFeedService.kt | 78 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedApi.kt create mode 100644 src/main/kotlin/com/coder/toolbox/feed/JetBrainsFeedService.kt 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() + ) + } + } + } +} From a87158126b8b186ec0fb7e1893ae66f51298e8c3 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 15 Dec 2025 23:11:04 +0200 Subject: [PATCH 3/7] impl: load and cache IDEs This commit implements an IDE feed manager that depending on whether Toolbox runs in offline mode or not loads the eap and release information from two local json files or from the https://data.services.jetbrains.com/products. The feed manager is also able to intersect a list of available build versions (the IDE versions available for install on a workspace) with the list of versions fetched from the feed and return the latest version available for install. The intersection happens only after the feed was filtered by product code release type. Support for offline mode is WIP. The commit assumes that in offline mode Toolbox exposes a certain system property. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../com/coder/toolbox/feed/IdeFeedManager.kt | 251 ++++++++++++++++ .../kotlin/com/coder/toolbox/feed/IdeQuery.kt | 24 ++ .../coder/toolbox/feed/IdeFeedManagerTest.kt | 271 ++++++++++++++++++ 5 files changed, 548 insertions(+) create mode 100644 src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt create mode 100644 src/main/kotlin/com/coder/toolbox/feed/IdeQuery.kt create mode 100644 src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt 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/feed/IdeFeedManager.kt b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt new file mode 100644 index 0000000..4113366 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt @@ -0,0 +1,251 @@ +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. + * + * Build comparison is done lexicographically (string comparison). + * + * @param query The query criteria specifying product code, type, and optional available builds + * @return The IDE with the highest build matching the criteria, or null if no match found + */ + suspend fun findBestMatch(query: IdeQuery): Ide? { + val ides = loadIdes() + + return ides + .filter { it.code == query.productCode } + .filter { it.type == query.type } + .let { filtered -> + filtered.filter { it.build in query.availableBuilds } + } + .maxByOrNull { it.build } + } + + /** + * Convenience method to find the best matching IDE. + * + * This is a shorthand for creating an IdeQuery and calling findBestMatch(query). + * + * @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? = findBestMatch( + IdeQuery(productCode, type, availableBuilds) + ) + + 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/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) + } +} From d5bf957a196afe540d6506ad1ea662fa086a2aaf Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Mon, 15 Dec 2025 23:12:12 +0200 Subject: [PATCH 4/7] impl: initialize and pass the feed manager to URI handler To be used later for resolving the placeholders. --- src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 3 ++- .../kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt | 2 ++ .../kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) 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/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index ae6d13a..33c3223 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -2,6 +2,7 @@ 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.models.WorkspaceAndAgentStatus import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace @@ -26,6 +27,7 @@ private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, + ideFeedManager: IdeFeedManager, ) { private val settings = context.settingsStore.readOnly() diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 326fce0..a973e54 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.feed.IdeFeedManager import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore @@ -54,7 +55,8 @@ internal class CoderProtocolHandlerTest { ) private val protocolHandler = CoderProtocolHandler( - context + context, + IdeFeedManager(context) ) @Test From ce3f67b88ebe6add53292f9497f26f5557ad968d Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 16 Dec 2025 00:20:32 +0200 Subject: [PATCH 5/7] impl: support for `latest_eap`, `latest_release` and `latest_installed` placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for the IDE placeholders in the URI build number parameter. This is a request coming from Netflix, the placeholders provide an easier way to fill in the build number from the web dashboard without having to know the available versions of IDE from Toolbox. IDE launch flow is refactored to support dynamic build selectors, and it also improves the install/launch logic. When latest_eap or latest_release is used: - prefer the newest matching version that is available for install - install it only if it isn’t already installed - fall back to the latest available version if no match exists - show an error if none of the above happens When latest_installed is used: - launch the newest installed version - if none are installed, install and launch the latest available version - show an error if none of the above happens --- .../toolbox/util/CoderProtocolHandler.kt | 155 +++++++++++++----- 1 file changed, 111 insertions(+), 44 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 33c3223..665f787 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -3,6 +3,7 @@ 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 @@ -27,7 +28,7 @@ private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" @Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, - ideFeedManager: IdeFeedManager, + private val ideFeedManager: IdeFeedManager, ) { private val settings = context.settingsStore.readOnly() @@ -236,72 +237,136 @@ 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 selectedIdeVersion = resolveIdeIdentifier(environmentId, productCode, buildNumberHint) ?: return null + val installedIdeVersions = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + + if (installedIdeVersions.contains(selectedIdeVersion)) { + context.logger.info("$selectedIdeVersion is already installed on $environmentId") + return selectedIdeVersion + } + + val selectedIde = "$productCode-$selectedIdeVersion" + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) - var selectedIde = "$productCode-$buildNumber" - if (installedIdes.firstOrNull { it.contains(buildNumber) } != null) { - context.logger.info("$selectedIde is already installed on $environmentId") + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIdeVersion 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 } + } - selectedIde = resolveAvailableIde(environmentId, productCode, buildNumber) ?: return null + /** + * 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) + + when (buildNumberHint) { + "latest_eap" -> { + // Use IdeFeedManager to find best EAP match from available builds + val bestEap = ideFeedManager.findBestMatch( + productCode, + IdeType.EAP, + availableBuilds + ) - // needed otherwise TBX will install it again - if (!installedIdes.contains(selectedIde)) { - context.logger.info("Installing $selectedIde on $environmentId...") - context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + return if (bestEap != null) { + bestEap.build + } else { + // Fallback to latest available if valid + if (availableBuilds.isEmpty()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$productCode is not available on $environmentId" + ) + return null + } + val fallback = availableBuilds.maxBy { it } + context.logger.info("No EAP found for $productCode, falling back to latest available: $fallback") + fallback + } + } - 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") + "latest_release" -> { + val bestRelease = ideFeedManager.findBestMatch( + productCode, + IdeType.RELEASE, + availableBuilds ) - return null + + return if (bestRelease != null) { + bestRelease.build + } else { + // Fallback to latest available if valid + if (availableBuilds.isEmpty()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$productCode is not 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 + } } - } else { - context.logger.info("$selectedIde is already present on $environmentId...") - return selectedIde - } - } - private suspend fun resolveAvailableIde(environmentId: String, productCode: String, buildNumber: String): String? { - val availableVersions = context - .remoteIdeOrchestrator - .getAvailableRemoteTools(environmentId, productCode) + "latest_installed" -> { + val installed = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + if (installed.isNotEmpty()) { + return installed.maxBy { it } + } + // Fallback to latest available if valid + if (availableBuilds.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") + return null + } + val fallback = availableBuilds.maxBy { it } + context.logger.info("No installed IDE found, falling back to latest available: $fallback") + return fallback + } - if (availableVersions.isEmpty()) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") - return null - } + else -> { + // Specific build number + // Check if exact match exists in available or installed (implicitly handled by install check later) + // Often the input buildNumber might be just "241" or "241.1234", but full build version is in the form of 241.1234.234" - 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 + return availableBuilds.filter { it.contains(buildNumberHint) }.maxByOrNull { it } + } } - return "$productCode-$buildNumber" } private fun installJBClient(selectedIde: String, environmentId: String): Job = @@ -340,7 +405,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 From 98dc1261c4865672f6a401ca1435fca4461b3d38 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 17 Dec 2025 00:08:12 +0200 Subject: [PATCH 6/7] impl: UTs for URI handling and a couple of other optimizations --- .../com/coder/toolbox/feed/IdeFeedManager.kt | 34 +- .../toolbox/util/CoderProtocolHandler.kt | 53 +-- .../toolbox/util/CoderProtocolHandlerTest.kt | 330 +++++++----------- 3 files changed, 174 insertions(+), 243 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt index 4113366..6e54503 100644 --- a/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt +++ b/src/main/kotlin/com/coder/toolbox/feed/IdeFeedManager.kt @@ -208,28 +208,6 @@ class IdeFeedManager( * This method filters the loaded IDEs by product code and type, optionally * filtering by available builds, then returns the IDE with the highest build. * - * Build comparison is done lexicographically (string comparison). - * - * @param query The query criteria specifying product code, type, and optional available builds - * @return The IDE with the highest build matching the criteria, or null if no match found - */ - suspend fun findBestMatch(query: IdeQuery): Ide? { - val ides = loadIdes() - - return ides - .filter { it.code == query.productCode } - .filter { it.type == query.type } - .let { filtered -> - filtered.filter { it.build in query.availableBuilds } - } - .maxByOrNull { it.build } - } - - /** - * Convenience method to find the best matching IDE. - * - * This is a shorthand for creating an IdeQuery and calling findBestMatch(query). - * * @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 @@ -239,9 +217,15 @@ class IdeFeedManager( productCode: String, type: IdeType, availableBuilds: List - ): Ide? = findBestMatch( - IdeQuery(productCode, type, availableBuilds) - ) + ): 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" diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index 665f787..0590244 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -257,20 +257,19 @@ open class CoderProtocolHandler( buildNumberHint: String, environmentId: String ): String? { - val selectedIdeVersion = resolveIdeIdentifier(environmentId, productCode, buildNumberHint) ?: return null + val selectedIde = resolveIdeIdentifier(environmentId, productCode, buildNumberHint) ?: return null val installedIdeVersions = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) - if (installedIdeVersions.contains(selectedIdeVersion)) { - context.logger.info("$selectedIdeVersion is already installed on $environmentId") - return selectedIdeVersion + if (installedIdeVersions.contains(selectedIde)) { + context.logger.info("$selectedIde is already installed on $environmentId") + return selectedIde } - val selectedIde = "$productCode-$selectedIdeVersion" context.logger.info("Installing $selectedIde on $environmentId...") context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { - context.logger.info("Successfully installed $selectedIdeVersion on $environmentId.") + context.logger.info("Successfully installed $selectedIde on $environmentId.") return selectedIde } else { context.ui.showSnackbar( @@ -293,10 +292,12 @@ open class CoderProtocolHandler( 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" -> { - // Use IdeFeedManager to find best EAP match from available builds val bestEap = ideFeedManager.findBestMatch( productCode, IdeType.EAP, @@ -306,14 +307,14 @@ open class CoderProtocolHandler( return if (bestEap != null) { bestEap.build } else { - // Fallback to latest available if valid if (availableBuilds.isEmpty()) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$productCode is not available on $environmentId" + "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 @@ -324,17 +325,15 @@ open class CoderProtocolHandler( val bestRelease = ideFeedManager.findBestMatch( productCode, IdeType.RELEASE, - availableBuilds - ) + availableBuilds.map { it.substringAfter("$productCode-") }) return if (bestRelease != null) { bestRelease.build } else { - // Fallback to latest available if valid if (availableBuilds.isEmpty()) { context.logAndShowError( CAN_T_HANDLE_URI_TITLE, - "$productCode is not available on $environmentId" + "Can't launch Release for $productCode because no version is available on $environmentId" ) return null } @@ -345,26 +344,40 @@ open class CoderProtocolHandler( } "latest_installed" -> { - val installed = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) if (installed.isNotEmpty()) { return installed.maxBy { it } } - // Fallback to latest available if valid if (availableBuilds.isEmpty()) { - context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") + 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 - // Check if exact match exists in available or installed (implicitly handled by install check later) - // Often the input buildNumber might be just "241" or "241.1234", but full build version is in the form of 241.1234.234" + // 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 availableBuilds.filter { it.contains(buildNumberHint) }.maxByOrNull { it } } } } diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index a973e54..4b36867 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -1,229 +1,163 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.feed.Ide import com.coder.toolbox.feed.IdeFeedManager -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.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, - IdeFeedManager(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("RR-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("RR-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("RR-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("RR-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("RR-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") - internal data class AgentMatchTestCase( - val description: String, - val params: Map, - val expectedAgentId: UUID - ) + assertEquals("RR-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 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("RR-241.1", handler.resolveIdeIdentifier("env-1", "RR", "241.1")) + } + + @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("RR-251.1", handler.resolveIdeIdentifier("env-1", "RR", "251.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") - internal data class AgentNullResultTestCase( - val description: String, - val params: Map - ) + assertNull(handler.resolveIdeIdentifier("env-1", "RR", "221.1")) + } + + @Test + fun `given specific build does not exist in available tools when resolving specific build then constructs identifier`() = + runTest(dispatcher) { + coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-242.1") + + assertEquals("RR-200.1", handler.resolveIdeIdentifier("env-1", "RR", "200.1")) + } } \ No newline at end of file From 1be241312d72b8a1d9e0d636cf29651cec5526d5 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 17 Dec 2025 01:30:06 +0200 Subject: [PATCH 7/7] fix: UTs and add more scenarios --- .../toolbox/util/CoderProtocolHandlerTest.kt | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 4b36867..16fa41f 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -55,7 +55,7 @@ class CoderProtocolHandlerTest { // Feed returns empty or irrelevant EAPs coEvery { feedService.fetchEapFeed() } returns emptyList() - assertEquals("RR-241.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_eap")) + assertEquals("241.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_eap")) } @Test @@ -68,7 +68,7 @@ class CoderProtocolHandlerTest { Ide("RR", "243.1", "2024.3", IdeType.EAP) ) - assertEquals("RR-243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_eap")) + assertEquals("243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_eap")) } @Test @@ -84,7 +84,7 @@ class CoderProtocolHandlerTest { coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-243.1", "RR-242.1") coEvery { feedService.fetchReleaseFeed() } returns emptyList() - assertEquals("RR-243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_release")) + assertEquals("243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_release")) } @Test @@ -97,7 +97,7 @@ class CoderProtocolHandlerTest { Ide("RR", "242.1", "2024.2", IdeType.RELEASE) ) - assertEquals("RR-242.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_release")) + assertEquals("242.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_release")) } @Test @@ -105,7 +105,7 @@ class CoderProtocolHandlerTest { runTest(dispatcher) { coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns listOf("RR-240.1", "RR-241.1") - assertEquals("RR-241.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_installed")) + assertEquals("241.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_installed")) } @Test @@ -114,7 +114,7 @@ class CoderProtocolHandlerTest { coEvery { remoteToolsHelper.getInstalledRemoteTools("env-1", "RR") } returns emptyList() coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-243.1", "RR-242.1") - assertEquals("RR-243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_installed")) + assertEquals("243.1", handler.resolveIdeIdentifier("env-1", "RR", "latest_installed")) } @Test @@ -127,25 +127,25 @@ class CoderProtocolHandlerTest { } @Test - fun `given specific build exists in available tools but not in installed then expect the available match to be returned`() = + 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("RR-241.1", handler.resolveIdeIdentifier("env-1", "RR", "241.1")) + assertEquals("251.1", handler.resolveIdeIdentifier("env-1", "RR", "251.1")) } @Test - fun `given specific build exists in installed tools but not in available then expect the installed match to be returned`() = + 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("RR-251.1", handler.resolveIdeIdentifier("env-1", "RR", "251.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`() = + 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") @@ -154,10 +154,19 @@ class CoderProtocolHandlerTest { } @Test - fun `given specific build does not exist in available tools when resolving specific build then constructs identifier`() = + fun `installed build takes precedence over available tools when matching a build number`() = runTest(dispatcher) { - coEvery { remoteToolsHelper.getAvailableRemoteTools("env-1", "RR") } returns listOf("RR-242.1") + 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("RR-200.1", handler.resolveIdeIdentifier("env-1", "RR", "200.1")) + assertEquals("241.1.2", handler.resolveIdeIdentifier("env-1", "RR", "241.1")) } + + } \ No newline at end of file