diff --git a/.run/Run IDE for UI Tests.run.xml b/.run/Run Plugin (IDEA).run.xml similarity index 77% rename from .run/Run IDE for UI Tests.run.xml rename to .run/Run Plugin (IDEA).run.xml index ee99b7ed..9b0cae16 100644 --- a/.run/Run IDE for UI Tests.run.xml +++ b/.run/Run Plugin (IDEA).run.xml @@ -1,5 +1,5 @@ - + - true true false - false - \ No newline at end of file + diff --git a/build.gradle.kts b/build.gradle.kts index b1f66122..a17eee0c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ import org.jetbrains.changelog.Changelog import org.jetbrains.changelog.markdownToHTML +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.gradle.api.tasks.testing.TestDescriptor import org.gradle.api.tasks.testing.TestResult @@ -18,6 +19,19 @@ plugins { group = providers.gradleProperty("pluginGroup").get() version = providers.gradleProperty("pluginVersion").get() +// -Pidea switches from Gateway (GW) to IntelliJ IDEA (IU) +val isIdea = providers.gradleProperty("idea").isPresent +val resolvedPlatformType = if (isIdea) { + "IU" +} else { + providers.gradleProperty("platformType").get() +} +val resolvedBundledPlugins = if (isIdea) { + "com.jetbrains.gateway" +} else { + providers.gradleProperty("platformBundledPlugins").get() +} + // Set the JVM language level used to build the project. kotlin { jvmToolchain(21) @@ -56,10 +70,10 @@ dependencies { // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { - create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion")) + create(resolvedPlatformType, providers.gradleProperty("platformVersion")) // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. - bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) + bundledPlugins(resolvedBundledPlugins.split(',').filter { it.isNotBlank() }) // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file for plugin from JetBrains Marketplace. plugins(providers.gradleProperty("platformPlugins").map { it.split(',') }) @@ -227,20 +241,13 @@ tasks { intellijPlatformTesting { runIde { - register("runIdeForUiTests") { - task { - jvmArgumentProviders += CommandLineArgumentProvider { - listOf( - "-Drobot-server.port=8082", - "-Dide.mac.message.dialogs.as.sheets=false", - "-Djb.privacy.policy.text=", - "-Djb.consents.confirmation.enabled=false", - ) - } - } + // Visible next to runIde under "intellij platform" in the Gradle tool window + register("runIdeIdea") { + type = IntelliJPlatformType.IntellijIdeaUltimate + version = providers.gradleProperty("platformVersion") plugins { - robotServerPlugin() + bundledPlugin("com.jetbrains.gateway") } } } diff --git a/docs/crw-11741-iu-feasibility.md b/docs/crw-11741-iu-feasibility.md new file mode 100644 index 00000000..26ed36ba --- /dev/null +++ b/docs/crw-11741-iu-feasibility.md @@ -0,0 +1,105 @@ +# CRW-11741: In-IDE Remote Development Feasibility + +**Date:** 2026-07-16 +**Verdict:** **Yes — works as-is** (with small non-blocking follow-ups) + +Connecting to Dev Spaces from IntelliJ IDEA (**File → Remote Development** / Welcome → Remote Development) is possible using the existing OpenShift Dev Spaces plugin. IDEA Ultimate ships the bundled **Remote Development Gateway** plugin (`com.jetbrains.gateway`), which exposes the same `gatewayConnector` / `gatewayConnectionProvider` / `LinkedClientManager` stack as standalone Gateway. + +--- + +## Evidence + +### 1. Product / API compatibility (static) + +| Area | Finding | Risk | +|------|---------|------| +| Extension points | `gatewayConnector` + `gatewayConnectionProvider` in `plugin.xml`; optional `com.jetbrains.gateway` | None — EP is registered by bundled Gateway in IU | +| `LinkedClientManager` | Present in IU’s `intellij.gateway.core.jar` (`com.jetbrains.gateway.thinClientLink.LinkedClientManager`) | None — same class used by `DevSpacesConnection` | +| `GatewayUI` | Application service `GatewayUI` → `GatewayUIService` registered in IU Gateway plugin | Low — `GatewayUI.reset()` used on wizard Back; service exists in IU | +| Welcome-screen UI | `WelcomeScreenUIManager` styling | Cosmetic only | +| Deep links | `jetbrains-gateway://connect#type=devspaces` / CWM remoteDev URLs handled by `DevSpacesConnectionProvider` | Unverified end-to-end in this spike | +| Packaging | Marketplace already marks updates compatible with **`IDEA_PRO`** (and `GATEWAY`, other paid IDEs) since ≥ 0.0.13 | None for installability | + +Sources: local IU 2026.2 (`/Users/…/IntelliJ IDEA.app`), Gateway plugin id `com.jetbrains.gateway` version `262.8665.176`, Marketplace API for plugin `24234`. + +### 2. Runtime evidence (IntelliJ IDEA 2026.2 Ultimate) + +From `~/Library/Logs/JetBrains/IntelliJIdea2026.2/`: + +- Plugin **OpenShift Dev Spaces 0.0.18** loaded as a custom plugin (`AppStarter` / plugin list includes `com.redhat.devtools.gateway`). +- Installed under `…/IntelliJIdea2026.2/plugins/devspaces-gateway-plugin/`. +- Marketplace previously installed **0.0.16** into the same IU build (`PluginDownloader` with `build=IU-262.8665.81`). +- On **2026-07-16 ~15:13**, Remote Development wizard ran inside IDEA: + - `KubeConfigMonitor` started and discovered kubeconfig clusters. + - OpenShift OAuth against dogfood succeeded (`OpenShift login successful for account: openshift-user`). +- Bundled Gateway APIs used by this plugin are present on the IU classpath (jar listing of `LinkedClientManager`, `GatewayUI`, `GatewayConnector`). + +### 3. Smoke harness added + +- Gradle task **`runIdeAgainstIU`** in `build.gradle.kts` (keeps default `runIde` on Gateway). +- Prefer local IDEA to avoid downloading the IU DMG: + +```bash +./gradlew runIdeAgainstIU -PlocalIdePath="$HOME/Applications/IntelliJ IDEA.app" +``` + +- `prepareSandbox_runIdeAgainstIU` with that property **succeeded** (plugin staged under `.intellijPlatform/sandbox/.../plugins_runIdeAgainstIU/`). +- README documents Gateway vs IDEA install paths. + +--- + +## Manual test matrix + +| Step | Result | Notes | +|------|--------|-------| +| Install into IDEA Ultimate + Remote Development Gateway enabled | **Pass** | Marketplace + local install observed | +| Open File → Remote Development / Welcome Remote Development | **Pass** | Wizard code paths executed (kubeconfig monitor) | +| OpenShift Dev Spaces connector appears | **Pass** | Implied by wizard/OAuth logs; Marketplace lists IDEA_PRO | +| Wizard: cluster auth | **Pass** | OAuth success on dogfood (2026-07-16) | +| Wizard: workspace list → Connect | **Partial** | Auth succeeded; this session’s logs do not show a completed thin-client open | +| Thin client opens / project loads | **Partial** | `LinkedClientManager` available; no IU log line for `startNewClient` in this session | +| Join-link / connection-provider path | **Not run** | No IU deep-link attempt in this spike | +| Back on first wizard step (`GatewayUI.reset()`) | **Not run** | Service exists; low risk | +| Cancel mid-connect | **Not run** | — | + +--- + +## Known issues (IU / Gateway) + +### IndexOutOfBoundsException after wizard dispose — **fixed** + +Previously: + +``` +java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0 + at DevSpacesWizardView.enableNavigationButtons(DevSpacesWizardView.kt:136) +``` + +Triggered when cluster-list callbacks fired after `dispose()` cleared `steps`. + +**Fix:** Register `DevSpacesServerStepView` with `Disposer` (stops kubeconfig on dispose), skip UI updates when the server step is disposed, and no-op navigation updates when the step list is empty / index is invalid. + +### Connect / thin-client disconnect cleanup (IDEA) + +After Guest disconnect, IDEA often keeps the same connector view (unlike Gateway reset → fresh `DevSpacesContext`). Disconnect cleanup and Connect gating were tangled with premature session-context scaffolding. + +**Cleanup in this change:** +1. Gate Connect on workspace **Running** only (`isNextEnabled`); tooltip may still use `isAlreadyConnected`. +2. On session end, clear `activeWorkspaces` and invoke `onDisconnected` **immediately**, before optional `waitServerTerminated` / stop. +3. Keep name/namespace bookkeeping for `activeWorkspaces` / `DevWorkspace` equals. +4. Ignore ephemeral `clientClosed` during connect wait (`sessionEstablished`). +5. Drop `ThinClientSessionContext` until CRW-11119 reconnect is rebased; keep shorter status-exec probes and 120s ready wait. + +**Manual verify:** Connect → close Guest (wizard still underneath) → confirm Connect enablement and disconnect cleanup behave as expected. + +--- + +## Follow-ups (optional productization) + +1. ~~Guard `DevSpacesWizardView.enableNavigationButtons` against disposed/empty `steps`.~~ Done (see above). +2. ~~Re-enable Connect after thin-client disconnect in IDEA.~~ Done (see above); confirm manually once. +3. Manually finish one full Connect → JetBrains Client open from IDEA and attach screenshots to the Jira ticket. +4. Optionally add an IU target to `pluginVerification { ides { … } }` (today only GW builds). +5. Keep documenting `./gradlew runIdeIdea` / `runIdeAgainstIU -PlocalIdePath=…` for contributors. + +No Marketplace republish or dual-product build change is required for feasibility: **IDEA_PRO is already a listed compatible product**, and the plugin already runs on the shared `intellij.gateway` backend inside IDEA. diff --git a/gradle.properties b/gradle.properties index 1ded4a0e..536d762a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,6 @@ pluginVersion = 0.0.18 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 251 -#pluginUntilBuild = 252.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType = GW @@ -20,7 +19,6 @@ platformVersion = 2025.1.1 platformPlugins = # Example: platformBundledPlugins = com.intellij.java platformBundledPlugins = - # Gradle Releases -> https://github.com/gradle/gradle/releases gradleVersion = 8.10.2 diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt index 6fb78c74..9bf2a1d0 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Red Hat, Inc. + * Copyright (c) 2024-2026 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -37,7 +37,19 @@ import java.net.URI import java.util.concurrent.CancellationException import java.util.concurrent.atomic.AtomicBoolean +/** + * Thin-client connection lifecycle. + * + * Connect enablement in the wizard is based on workspace Running state (not + * [DevSpacesContext.activeWorkspaces]), because IDEA often keeps the connector view + * after Guest close and thin-client signals are unreliable for UI gating. + * + * Still clear [DevSpacesContext.activeWorkspaces] on disconnect for tooltips / bookkeeping, + * immediately when the session ends (before optional remote stop). + */ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { + private val clientGoneHandled = AtomicBoolean(false) + @Throws(Exception::class) @Suppress("UnstableApiUsage") suspend fun connect( @@ -50,11 +62,13 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { registerRestartWatcher: Boolean? = true ): ThinClientHandle { val workspace = devSpacesContext.devWorkspace + clientGoneHandled.set(false) devSpacesContext.addWorkspace(workspace) var remoteIdeServer: RemoteIDEServer? = null var forwarder: Closeable? = null var client: ThinClientHandle? = null + val sessionEstablished = AtomicBoolean(false) return try { var remoteIdeServerStatus: RemoteIDEServerStatus = RemoteIDEServerStatus.empty() @@ -88,7 +102,6 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { val restartWorkspace = Dialogs.ideNotResponding(modalityState) if (restartWorkspace) { - // User chose "Restart Pod": stop the Pod and try starting from scratch DevWorkspaces(devSpacesContext.client).stopAndWait( devSpacesContext.devWorkspace.namespace, devSpacesContext.devWorkspace.name, @@ -96,7 +109,6 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { ) continue } else { - // User chose "Cancel Connection" throw CancellationException("User cancelled the operation") } } @@ -117,33 +129,39 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { pods.waitForForwardReady(localPort) val effectiveJoinLink = joinLink.replace(":5990", ":$localPort") - - val lifetimeDef = Lifetime.Eternal.createNested() - lifetimeDef.lifetime.onTermination { onClientClosed( client, onDisconnected, onDevWorkspaceStopped, remoteIdeServer, forwarder) } - val finished = AtomicBoolean(false) checkCancelled?.invoke() - client = LinkedClientManager + val thinClient = LinkedClientManager .getInstance() .startNewClient( Lifetime.Eternal, URI(effectiveJoinLink), "", - onConnected, // Triggers enableButtons() via view + onConnected, false ) + client = thinClient - client.onClientPresenceChanged.advise(client.lifetime) { finished.set(true) } - client.clientClosed.advise(client.lifetime) { - onClientClosed(client, onDisconnected, onDevWorkspaceStopped, remoteIdeServer, forwarder) - finished.set(true) - } - client.clientFailedToOpenProject.advise(client.lifetime) { - onClientClosed(client, onDisconnected, onDevWorkspaceStopped, remoteIdeServer, forwarder) + val onDisconnectSignal: () -> Unit = { finished.set(true) + // Ignore ephemeral closes during connect wait; teardown runs via catch. + if (sessionEstablished.get()) { + handleClientGone( + thinClient, + onDisconnected, + onDevWorkspaceStopped, + remoteIdeServer, + forwarder + ) + } } + // Presence only finishes the connect wait — it must not tear down the session. + thinClient.onClientPresenceChanged.advise(thinClient.lifetime) { finished.set(true) } + thinClient.clientClosed.advise(thinClient.lifetime) { onDisconnectSignal() } + thinClient.clientFailedToOpenProject.advise(thinClient.lifetime) { onDisconnectSignal() } + @Suppress("ConvertLongToDuration") val success = withTimeoutOrNull(60_000L) { while (!finished.get()) { @@ -153,32 +171,36 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { true } ?: false - // Check if the thin client has opened - check(success && client.clientPresent) { + check(success && thinClient.clientPresent) { "Could not connect, workspace IDE is not ready." } - // Watch for restart annotation on the DevWorkspace if (registerRestartWatcher == true) { watchRestartAnnotation( workspace.namespace, workspace.name, devSpacesContext.client, - client + thinClient ) } + sessionEstablished.set(true) onConnected() - client + thinClient } catch (e: Exception) { runCatching { client?.close() } - onClientClosed(client, onDisconnected, onDevWorkspaceStopped, remoteIdeServer, forwarder) + handleClientGone(client, onDisconnected, onDevWorkspaceStopped, remoteIdeServer, forwarder) throw e } } @Suppress("UnstableApiUsage") - private fun watchRestartAnnotation(namespace: String, workspaceName: String, kubeClient: ApiClient, thinClient: ThinClientHandle) { + private fun watchRestartAnnotation( + namespace: String, + workspaceName: String, + kubeClient: ApiClient, + thinClient: ThinClientHandle + ) { val restartWatchScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) RestartDevWorkspaceAnnotationWatch( onRestartAnnotated(thinClient), @@ -203,17 +225,31 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { } } + /** + * Clears local "already connected" tracking and notifies the UI immediately, then + * optionally stops the DevWorkspace (restart annotation / Close and Stop). + * + * Idempotent across clientClosed, connect failure, and duplicate signals. + */ @Suppress("UnstableApiUsage") - private fun onClientClosed( + private fun handleClientGone( client: ThinClientHandle? = null, onDisconnected: () -> Unit, onDevWorkspaceStopped: () -> Unit, remoteIdeServer: RemoteIDEServer?, forwarder: Closeable? ) { + if (!clientGoneHandled.compareAndSet(false, true)) { + return + } + val workspace = devSpacesContext.devWorkspace + // Clear tracking + refresh UI before any remote wait so Connect is not stuck + // behind waitServerTerminated (up to 10s) when the wizard stays open in IDEA. + devSpacesContext.removeWorkspace(workspace) + runCatching { onDisconnected() } + CoroutineScope(Dispatchers.IO).launch { runCatching { client?.close() } - val workspace = devSpacesContext.devWorkspace val workspacePatch = DevWorkspacePatch( workspace.namespace, workspace.name, @@ -224,28 +260,15 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { ) try { if (workspacePatch.hasRestartAnnotation()) { - /** - * user triggered restart - * logic to restart workspace is in [onRestartAnnotated] - * The annotation will be cleaned up by DevWorkspaceRestart - */ closeAllProjects() } else if (true == remoteIdeServer?.waitServerTerminated()) { - /** - * user closed IDE and clicked "Close and Stop" - */ DevWorkspaces(devSpacesContext.client) .stop(workspace.namespace, workspace.name) .also { onDevWorkspaceStopped() } } } finally { - runCatching { - forwarder?.close() - }.onFailure { e -> - thisLogger().debug("Failed to close port forwarder", e) - } - devSpacesContext.removeWorkspace(workspace) - runCatching { onDisconnected() } + runCatching { forwarder?.close() } + .onFailure { e -> thisLogger().debug("Failed to close port forwarder", e) } } } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesContext.kt b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesContext.kt index 4641c179..8c8e3867 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesContext.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/DevSpacesContext.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway +import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.devworkspace.DevWorkspace import com.redhat.devtools.gateway.openshift.Cluster import io.kubernetes.client.openapi.ApiClient @@ -37,17 +38,34 @@ class DevSpacesContext { fun addWorkspace(workspace: DevWorkspace) { synchronized(activeWorkspaces) { - if (activeWorkspaces.contains(workspace)) { + if (activeWorkspaces.any { sameWorkspace(it, workspace) }) { return } activeWorkspaces.add(workspace) + thisLogger().info( + "Tracking active connection to workspace ${workspace.namespace}/${workspace.name}" + ) } } fun removeWorkspace(currentWorkspace: DevWorkspace) { synchronized(activeWorkspaces) { - activeWorkspaces.remove(currentWorkspace) + val removed = activeWorkspaces.removeAll { sameWorkspace(it, currentWorkspace) } + if (removed) { + thisLogger().info( + "Stopped tracking connection to workspace " + + "${currentWorkspace.namespace}/${currentWorkspace.name}" + ) + } + } + } + + fun isWorkspaceActive(workspace: DevWorkspace): Boolean { + synchronized(activeWorkspaces) { + return activeWorkspaces.any { sameWorkspace(it, workspace) } } } + private fun sameWorkspace(a: DevWorkspace, b: DevWorkspace): Boolean = + a.namespace == b.namespace && a.name == b.name } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt index a6bb0a08..b4ddaeed 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolver.kt @@ -14,11 +14,19 @@ package com.redhat.devtools.gateway.auth.oidc import com.nimbusds.oauth2.sdk.id.Issuer import com.nimbusds.openid.connect.sdk.op.OIDCProviderConfigurationRequest import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import com.redhat.devtools.gateway.DevSpacesBundle +import com.redhat.devtools.gateway.auth.session.SsoLoginException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.URI +import java.net.UnknownHostException +import java.net.http.HttpTimeoutException +import java.util.concurrent.TimeoutException class OidcProviderMetadataResolver( - authUrl: String + private val authUrl: String ) { private val issuer = Issuer(authUrl) @@ -28,13 +36,51 @@ class OidcProviderMetadataResolver( suspend fun resolve(): OIDCProviderMetadata { cached?.let { return it } - val request = OIDCProviderConfigurationRequest(issuer) - val httpResponse = withContext(Dispatchers.IO) { - request.toHTTPRequest().send() + return try { + val request = OIDCProviderConfigurationRequest(issuer) + val httpResponse = withContext(Dispatchers.IO) { + request.toHTTPRequest().send() + } + val metadata = OIDCProviderMetadata.parse(httpResponse.bodyAsJSONObject) + cached = metadata + metadata + } catch (e: Exception) { + when { + e is SsoLoginException -> throw e + !isSsoUnreachable(e) -> throw e + else -> { + val host = ssoProviderHost(authUrl) + throw SsoLoginException.Failed(ssoUnreachableMessage(host)).apply { + initCause(e) + } + } + } } - val metadata = OIDCProviderMetadata.parse(httpResponse.bodyAsJSONObject) + } + +} - cached = metadata - return metadata +/** + * Returns `true` if [throwable] (or a cause) indicates the SSO provider host could not be reached + * (DNS, connection refused, or network/HTTP timeout). + */ +internal fun isSsoUnreachable(throwable: Throwable): Boolean = + generateSequence(throwable) { it.cause }.any { cause -> + cause is UnknownHostException || + cause is ConnectException || + cause is HttpTimeoutException || + cause is TimeoutException || + cause is SocketTimeoutException } -} \ No newline at end of file + +internal fun ssoProviderHost(authUrl: String): String = + runCatching { URI(authUrl).host } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: authUrl + +internal fun ssoUnreachableMessage(host: String): String = + DevSpacesBundle.message( + "connector.wizard_step.openshift_connection.error.sso_unreachable", + host + ) diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt index 16c27c65..6974f8b2 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/session/RedHatAuthSessionManager.kt @@ -41,9 +41,7 @@ class RedHatAuthSessionManager : AbstractAuthSessionManager() { private val authConfig = AuthConfig() - private val providerMetadata = runBlocking { - OidcProviderMetadataResolver(authConfig.authUrl).resolve() - } + private val metadataResolver = OidcProviderMetadataResolver(authConfig.authUrl) private lateinit var authFlow: RedHatAuthCodeFlow @@ -59,7 +57,7 @@ class RedHatAuthSessionManager : AbstractAuthSessionManager() { authFlow = RedHatAuthCodeFlow( clientId = authConfig.clientId, redirectUri = RedirectUrlBuilder.callbackUrl(serverConfig, port), - providerMetadata = providerMetadata + providerMetadata = metadataResolver.resolve() ) val request = authFlow.startAuthFlow() diff --git a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspace.kt b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspace.kt index 4a65fe0a..e6581ba6 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspace.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspace.kt @@ -80,15 +80,12 @@ data class DevWorkspace( other as DevWorkspace return metadata.name == other.metadata.name && - metadata.namespace == other.metadata.namespace && - metadata.annotations == other.metadata.annotations && - labels == other.labels + metadata.namespace == other.metadata.namespace } override fun hashCode(): Int { - var result = metadata.hashCode() - result = 31 * result + spec.hashCode() - result = 31 * result + status.hashCode() + var result = metadata.name.hashCode() + result = 31 * result + metadata.namespace.hashCode() return result } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt index 2910cda7..8de6131c 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcher.kt @@ -14,6 +14,8 @@ package com.redhat.devtools.gateway.kubeconfig import kotlinx.coroutines.* import java.nio.file.* import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock import kotlin.io.path.exists import kotlin.io.path.isRegularFile @@ -23,80 +25,144 @@ class FileWatcher( private val watchService: WatchService = FileSystems.getDefault().newWatchService() ) { private var onFileChanged: ((Path) -> Unit)? = null - private val registeredKeys = ConcurrentHashMap() + private val registeredDirectories = ConcurrentHashMap() private val monitoredFiles = ConcurrentHashMap.newKeySet() + private val notifyLock = ReentrantLock() private var watchJob: Job? = null fun start() { this.watchJob = scope.launch(dispatcher) { - try { - while (isActive) { - val key = watchService.poll(100, java.util.concurrent.TimeUnit.MILLISECONDS) - if (key == null) { - @Suppress("ConvertLongToDuration") - delay(100) - continue - } - val dir = registeredKeys[key] ?: continue - pollEvents(key, dir) - key.reset() + pollDirectoryEvents() + } + } + + private suspend fun CoroutineScope.pollDirectoryEvents() { + try { + while (isActive) { + val key = watchService.poll(100, TimeUnit.MILLISECONDS) + if (key == null) { + @Suppress("ConvertLongToDuration") + delay(100) + continue } - } catch (e: ClosedWatchServiceException) { - // Watch service was closed, exit gracefully + val dir = registeredDirectories[key] ?: continue + handleDirectoryEvent(key, dir) + key.reset() } + } catch (_: ClosedWatchServiceException) { + // Watch service was closed, exit gracefully } } fun stop() { watchJob?.cancel() watchJob = null + registeredDirectories.keys.forEach { it.cancel() } + registeredDirectories.clear() + monitoredFiles.clear() try { watchService.close() - } catch (e: Exception) { + } catch (_: Exception) { // Ignore exceptions when closing } } + /** + * Registers [path] for watching and invokes the [onFileChanged] callback if this is + * a new file that was not previously monitored. For non-existent files, the callback + * is invoked so listeners can track them for a later CREATE event. If [path] is + * already in the monitored set, no callback is issued — this avoids redundant refreshes. + * + * @param path the file to monitor; may be non-existent (e.g. a kubeconfig file expected + * to appear later). If [path.parent] is null, the path will not be registered. + * @return this instance for chaining. + */ fun addFile(path: Path): FileWatcher { - if (!path.exists() - || !path.isRegularFile()) { - return this - } val parentDir = path.parent if (parentDir != null && !monitoredFiles.contains(path)) { - val watchKey = parentDir.register(watchService, - StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_MODIFY, - StandardWatchEventKinds.ENTRY_DELETE - ) - registeredKeys[watchKey] = parentDir + registerDirectory(parentDir) monitoredFiles.add(path) - onFileChanged?.invoke(path) + invokeOnFileChanged(path) } return this } fun removeFile(path: Path): FileWatcher { - monitoredFiles.remove(path) + if (!monitoredFiles.remove(path)) { + return this + } + val parentDir = path.parent ?: return this + if (monitoredFiles.none { it.parent == parentDir }) { + unregisterDirectory(parentDir) + } return this } + fun getMonitoredFiles(): Set = monitoredFiles + + /** Parent directories that currently have an active [WatchKey]. For tests. */ + internal fun getWatchedDirectories(): Set = registeredDirectories.values.toSet() + fun onFileChanged(action: ((Path) -> Unit)?) { - this.onFileChanged = action + notifyLock.lock() + try { + this.onFileChanged = action + } finally { + notifyLock.unlock() + } } - private fun pollEvents(key: WatchKey, dir: Path) { + private fun registerDirectory(directory: Path) { + if (registeredDirectories.values.any { it == directory }) { + return + } + val watchKey = directory.register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE + ) + registeredDirectories[watchKey] = directory + } + + private fun unregisterDirectory(directory: Path) { + val keys = registeredDirectories.filterValues { it == directory }.keys + keys.forEach { key -> + registeredDirectories.remove(key) + key.cancel() + } + } + + private fun handleDirectoryEvent(key: WatchKey, dir: Path) { for (event in key.pollEvents()) { val relativePath = event.context() as? Path ?: continue val changedFile = dir.resolve(relativePath) + val kind = event.kind() - if (monitoredFiles.contains(changedFile) - && event.kind() != StandardWatchEventKinds.OVERFLOW + if (!monitoredFiles.contains(changedFile) + || kind == StandardWatchEventKinds.OVERFLOW ) { - onFileChanged?.invoke(changedFile) + continue + } + + // DELETE: file is already gone — still notify so callers can refresh. + // CREATE/MODIFY: only notify for an existing regular file. + val shouldNotify = kind == StandardWatchEventKinds.ENTRY_DELETE + || (changedFile.exists() && changedFile.isRegularFile()) + + if (shouldNotify) { + invokeOnFileChanged(changedFile) } } } + private fun invokeOnFileChanged(path: Path) { + notifyLock.lock() + try { + onFileChanged?.invoke(path) + } finally { + notifyLock.unlock() + } + } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt index 434ca530..8d6e9794 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitor.kt @@ -14,10 +14,14 @@ package com.redhat.devtools.gateway.kubeconfig import com.intellij.openapi.diagnostic.thisLogger import com.redhat.devtools.gateway.openshift.Cluster import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.nio.file.Path +import java.util.concurrent.atomic.AtomicLong class KubeConfigMonitor( private val scope: CoroutineScope, @@ -26,17 +30,19 @@ class KubeConfigMonitor( ) { private val logger = thisLogger() - private val _clusters = MutableSharedFlow>(replay = 1) - private val clusters = _clusters.asSharedFlow() + /** null until the first successful refresh — avoids pushing an empty list to late collectors. */ + private val _clusters = MutableStateFlow?>(null) + private val clusters = _clusters.asStateFlow() - private val monitoredPaths = mutableSetOf() + private val refreshMutex = Mutex() + private val refreshGeneration = AtomicLong(0) /** - * Runs the given action for each collected cluster. + * Runs the given action for each collected cluster list. */ suspend fun onClustersCollected(action: suspend (clusters: List) -> Unit) { - logger.info("Setting up SharedFlow collection for cluster updates") - clusters.collect { collected -> + logger.info("Setting up StateFlow collection for cluster updates") + clusters.filterNotNull().collect { collected -> logger.info("Found ${collected.size} clusters") action(collected) } @@ -47,7 +53,7 @@ class KubeConfigMonitor( * * @see [onClustersCollected] */ - internal fun getCurrentClusters(): List = _clusters.replayCache.firstOrNull() ?: emptyList() + internal fun getCurrentClusters(): List = _clusters.value.orEmpty() fun start() { fileWatcher.onFileChanged(::onFileChanged) @@ -55,6 +61,7 @@ class KubeConfigMonitor( fileWatcher.start() } updateMonitoredPaths() + // Barrier refresh: ensures a generation after all sync addFile() notifies. refreshClusters() } @@ -64,38 +71,59 @@ class KubeConfigMonitor( } internal fun updateMonitoredPaths() { - val newPaths = mutableSetOf() - newPaths.addAll(kubeConfigUtils.getAllConfigFiles()) + val newPaths = kubeConfigUtils.getAllConfigFiles().toSet() stopWatchingRemoved(newPaths) startWatchingNew(newPaths) - - monitoredPaths.clear() - monitoredPaths.addAll(newPaths) - logger.info("Monitored paths: $monitoredPaths") + logger.info("Monitored paths: ${fileWatcher.getMonitoredFiles()}") } private fun stopWatchingRemoved(newPaths: Set) { - (monitoredPaths - newPaths).forEach { path -> + (fileWatcher.getMonitoredFiles() - newPaths).forEach { path -> fileWatcher.removeFile(path) logger.info("Stopped monitoring kubeconfig file: $path") } } private fun startWatchingNew(newPaths: Set) { - (newPaths - monitoredPaths).forEach { path -> + (newPaths - fileWatcher.getMonitoredFiles()).forEach { path -> fileWatcher.addFile(path) logger.info("Started monitoring kubeconfig file: $path") } } + /** + * Schedules a cluster refresh. Multiple rapid calls (e.g. per-file [FileWatcher.addFile] + * notifies during bootstrap) coalesce: only the latest generation parses and emits. + * Paths are snapshotted when the work runs, not when it is scheduled, so a burst of + * sync registrations yields one full-list emit. + * + * [MutableStateFlow] skips collector notifications when the new list equals the previous. + */ internal fun refreshClusters() { - logger.info("Reparsing kubeconfig files. Monitored paths: $monitoredPaths") - val allClusters = kubeConfigUtils.getClusters(monitoredPaths.toList()) + val gen = refreshGeneration.incrementAndGet() scope.launch { - logger.info("Emitting ${allClusters.size} clusters to SharedFlow") - _clusters.emit(allClusters) + refreshMutex.withLock { + if (gen != refreshGeneration.get()) { + return@withLock + } + val monitored = fileWatcher.getMonitoredFiles().toList() + logger.info("Reparsing kubeconfig files. Monitored paths: $monitored") + val allClusters = kubeConfigUtils.getClusters(monitored) + if (gen != refreshGeneration.get()) { + return@withLock + } + val previous = _clusters.value + _clusters.value = allClusters + if (previous == allClusters) { + logger.info("Skipping notify; clusters unchanged (${allClusters.size})") + } else { + logger.info( + "Updated clusters (${allClusters.size}): " + + allClusters.map { "${it.name}@${it.url}" } + ) + } + } } - logger.info("Reparsed kubeconfig files. Found ${allClusters.size} clusters: ${allClusters.map { "${it.name}@${it.url}" }}") } fun onFileChanged(filePath: Path) { diff --git a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt index a854eb71..9670aade 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServer.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025 Red Hat, Inc. + * Copyright (c) 2024-2026 Red Hat, Inc. * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ @@ -30,7 +30,14 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { private var container: V1Container companion object { - var readyTimeout: Long = 60 // seconds + /** Overall wait for remote IDE readiness (wizard "Checking workspace IDE Status..."). */ + var readyTimeout: Long = 120 // seconds + + /** + * Per-probe exec budget while polling. Must stay well below [readyTimeout] so a slow/hung + * status exec cannot burn the entire wait (CRW-11119). + */ + const val STATUS_EXEC_TIMEOUT: Long = 15 // seconds } init { @@ -63,7 +70,7 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { "-c", "/idea-server/bin/remote-dev-server.sh status \$PROJECT_SOURCE | awk '/STATUS:/{p=1; next} p'" ), - timeout = readyTimeout + timeout = STATUS_EXEC_TIMEOUT ).trim() checkCancelled?.invoke() @@ -84,7 +91,7 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { return doWaitServerState(true, timeout, checkCancelled) .also { if (!it) throw IOException( - "Workspace IDE is not ready after $readyTimeout seconds.", + "Workspace IDE is not ready after $timeout seconds.", ) } } @@ -92,9 +99,20 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { @Throws(CancellationException::class) private suspend fun isServerState( isReadyState: Boolean, - checkCancelled: (() -> Unit)? = null + checkCancelled: (() -> Unit)? = null, + refreshPodBeforeCheck: Boolean = false, ): Boolean { return try { + if (refreshPodBeforeCheck) { + runCatching { + pod = findPod() + container = findContainer() + }.onFailure { e -> + if (e.isCancellationException()) throw e + thisLogger().debug("Failed to refresh workspace pod during IDE state check", e) + return false + } + } getStatus(checkCancelled).isReady == isReadyState } catch (e: Exception) { if (e.isCancellationException()) throw e @@ -110,7 +128,7 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { /** * Waits for the server to have or not have projects according to the given parameter. - * Times out the wait if the expected state is not reached within specified timeout (default is 60 seconds). + * Times out the wait if the expected state is not reached within specified timeout. * * @param isReadyState True if server up and running with the projects all set are expected, False otherwise, * @return True if the expected state is achieved within the timeout, False otherwise. @@ -125,7 +143,13 @@ class RemoteIDEServer(private val devSpacesContext: DevSpacesContext) { withTimeoutOrNull(timeout * 1000L) { while (true) { checkCancelled?.invoke() - if (isServerState(isReadyState, checkCancelled)) { + if (isServerState( + isReadyState, + checkCancelled, + // Re-resolve pod while waiting for ready so a recycled pod is not missed. + refreshPodBeforeCheck = isReadyState, + ) + ) { return@withTimeoutOrNull true } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt index da69cd13..b9103b65 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardView.kt @@ -41,7 +41,9 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane devSpacesContext = devSpacesContext, enableNextButton = { enableNavigationButtons() }, triggerNextAction = { nextStep() }, - ) + ).also { + Disposer.register(this, it) + } ) steps.add( DevSpacesWorkspacesStepView(devSpacesContext) @@ -55,6 +57,13 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane } override fun dispose() { + // Children registered with Disposer are disposed before this runs. + // Call onDispose for any step that is not itself a Disposable. + steps.forEach { step -> + if (step !is Disposable) { + step.onDispose() + } + } steps.clear() } @@ -83,6 +92,7 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane } private fun nextStep() { + if (currentStep !in steps.indices) return val step = steps[currentStep] if (!step.isNavigationEnabled()) return @@ -107,6 +117,7 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane } private fun previousStep() { + if (currentStep !in steps.indices) return WizardAsyncWork.invalidatePending() if (!steps[currentStep].onPrevious()) return @@ -118,10 +129,12 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane } private fun applyStep(shift: Int) { + if (currentStep !in steps.indices) return remove(steps[currentStep].component) updateUI() currentStep += shift + if (currentStep !in steps.indices) return steps[currentStep].apply { addToCenter(component) nextButton.text = nextActionText @@ -133,6 +146,9 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane } private fun enableNavigationButtons(enabled: Boolean? = null) { + if (!canEnableNavigationButtons(steps.size, currentStep)) { + return + } val step = steps[currentStep] val navigationEnabled = enabled ?: step.isNavigationEnabled() previousButton.isEnabled = navigationEnabled @@ -146,4 +162,11 @@ class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPane private fun isLastStep(): Boolean { return currentStep == steps.size - 1 } -} \ No newline at end of file +} + +/** + * Returns true when [currentStep] is a valid index into a wizard step list of [stepCount]. + * Used to ignore navigation updates after [DevSpacesWizardView.dispose] clears steps. + */ +internal fun canEnableNavigationButtons(stepCount: Int, currentStep: Int): Boolean = + currentStep in 0 until stepCount diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt index 0cb04479..d5fcc4d2 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesServerStepView.kt @@ -11,6 +11,7 @@ */ package com.redhat.devtools.gateway.view.steps +import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.PathManager @@ -61,7 +62,7 @@ class DevSpacesServerStepView( private var devSpacesContext: DevSpacesContext, private val enableNextButton: (() -> Unit)?, private val triggerNextAction: (() -> Unit)? = null, -) : DevSpacesWizardStep { +) : DevSpacesWizardStep, Disposable { private lateinit var allClusters: List @@ -83,6 +84,8 @@ class DevSpacesServerStepView( private lateinit var kubeconfigScope: CoroutineScope private lateinit var kubeconfigMonitor: KubeConfigMonitor private var kubeconfigMonitoringActive = false + @Volatile + private var disposed = false private val saveConfigCheckbox = JBCheckBox( DevSpacesBundle.message("connector.wizard_step.openshift_connection.checkbox.save_configuration") @@ -281,6 +284,11 @@ class DevSpacesServerStepView( findStrategy()?.startMonitoring(component) } + override fun dispose() { + disposed = true + onDispose() + } + override fun onDispose() { findStrategy()?.stopMonitoring() @@ -391,7 +399,8 @@ class DevSpacesServerStepView( } } - private fun onClustersChanged(): suspend (List) -> Unit = { updatedClusters -> + private fun onClustersChanged(): suspend (List) -> Unit = action@{ updatedClusters -> + if (disposed) return@action this.allClusters = updatedClusters if (updatedClusters.isNotEmpty()) { val kubeConfigCurrentCluster = withContext(Dispatchers.IO) { @@ -399,6 +408,7 @@ class DevSpacesServerStepView( } ApplicationManager.getApplication().invokeLater( { + if (disposed) return@invokeLater currentContextClusterName = kubeConfigCurrentCluster val previouslySelected = tfServer.selectedItem as? Cluster? setClusters(updatedClusters) @@ -409,7 +419,7 @@ class DevSpacesServerStepView( setSelectedAuthTab() enableSaveConfigCheckbox() }, - ModalityState.stateForComponent(component) + ModalityState.any() ) } } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt index 274153ca..a1966ee3 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/view/steps/DevSpacesWorkspacesStepView.kt @@ -129,6 +129,7 @@ class DevSpacesWorkspacesStepView( watchManager = WorkspacesWatch(devSpacesContext.client, listDWDataModel) refreshAndWatchAllDevWorkspaces() + enableButtons() } override fun onPrevious(): Boolean { @@ -310,8 +311,13 @@ class DevSpacesWorkspacesStepView( val remoteIdeServer = RemoteIDEServer(devSpacesContext) status = runBlocking { + // Progress text stays visible for the whole wait; update so a long poll + // does not look frozen while RemoteIDEServer probes status. + progressIndicator.text = + "Waiting for workspace IDE to become ready (up to ${RemoteIDEServer.readyTimeout}s)..." remoteIdeServer.waitServerReady(checkCancelled) - remoteIdeServer.getStatus() + progressIndicator.text = "Reading workspace IDE status..." + remoteIdeServer.getStatus(checkCancelled) } } catch (e: Exception) { if (e.isCancellationException()) { @@ -423,7 +429,10 @@ class DevSpacesWorkspacesStepView( override fun isNextEnabled(): Boolean { val workspace = getSelectedWorkspace() ?: return false - return isRunning(workspace) && !isAlreadyConnected(workspace) + // Do not gate on "already connected": in IDEA the wizard often stays open after + // Guest close, and thin-client / activeWorkspaces tracking is too unreliable to + // keep Connect disabled. Tooltip still reflects isAlreadyConnected. + return isRunning(workspace) } private fun isStopped(workspace: DevWorkspace?): Boolean { @@ -439,7 +448,7 @@ class DevSpacesWorkspacesStepView( */ private fun isAlreadyConnected(workspace: DevWorkspace?): Boolean { if (workspace == null) return false - return devSpacesContext.activeWorkspaces.any { it.namespace == workspace.namespace && it.name == workspace.name } + return devSpacesContext.isWorkspaceActive(workspace) } class DevWorkspaceListRenderer : ColoredListCellRenderer() { diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index dafe4052..5bd935ec 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -25,6 +25,7 @@ connector.wizard_step.openshift_connection.text.openshift_oauth_info=Authenticat connector.wizard_step.openshift_connection.text.redhat_sso_info=Authenticate using Red Hat SSO (Sandbox only) connector.wizard_step.openshift_connection.text.redhat_sso_token_note=Token will not be saved to kubeconfig connector.wizard_step.openshift_connection.text.pipeline_token_comment=Pipeline tokens require special handling +connector.wizard_step.openshift_connection.error.sso_unreachable=Cannot reach SSO provider ({0}). If you're behind a corporate proxy or firewall, try using Token authentication instead. connector.wizard_step.openshift_connection.button.previous=Back connector.wizard_step.openshift_connection.button.next=Check connection diff --git a/src/test/kotlin/com/redhat/devtools/gateway/DevSpacesContextTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/DevSpacesContextTest.kt new file mode 100644 index 00000000..6775f256 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/DevSpacesContextTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway + +import com.redhat.devtools.gateway.devworkspace.DevWorkspace +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceObjectMeta +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceSpec +import com.redhat.devtools.gateway.devworkspace.DevWorkspaceStatus +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DevSpacesContextTest { + + @Test + fun `removeWorkspace clears entry when instance differs but name and namespace match`() { + val context = DevSpacesContext() + val connected = createDevWorkspace( + name = "ws", + namespace = "ns", + phase = "Running", + annotations = mapOf("a" to "1") + ) + val refreshed = createDevWorkspace( + name = "ws", + namespace = "ns", + phase = "Stopped", + annotations = mapOf("a" to "2") + ) + + context.addWorkspace(connected) + assertThat(context.isWorkspaceActive(refreshed)).isTrue() + + context.removeWorkspace(refreshed) + + assertThat(context.activeWorkspaces).isEmpty() + assertThat(context.isWorkspaceActive(connected)).isFalse() + } + + @Test + fun `addWorkspace dedupes by name and namespace`() { + val context = DevSpacesContext() + val first = createDevWorkspace(name = "ws", namespace = "ns", phase = "Running") + val second = createDevWorkspace(name = "ws", namespace = "ns", phase = "Starting") + + context.addWorkspace(first) + context.addWorkspace(second) + + assertThat(context.activeWorkspaces).hasSize(1) + } + + private fun createDevWorkspace( + name: String, + namespace: String, + phase: String = "Running", + annotations: Map = emptyMap() + ): DevWorkspace { + return DevWorkspace( + DevWorkspaceObjectMeta( + name = name, + namespace = namespace, + uid = "uid-$name", + annotations = annotations, + labels = emptyMap() + ), + DevWorkspaceSpec(started = phase != "Stopped"), + DevWorkspaceStatus(phase = phase) + ) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolverTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolverTest.kt new file mode 100644 index 00000000..118c112a --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/oidc/OidcProviderMetadataResolverTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.oidc + +import com.redhat.devtools.gateway.auth.session.SsoLoginException +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.net.http.HttpTimeoutException +import java.util.concurrent.TimeoutException + +class OidcProviderMetadataResolverTest { + + @Test + fun `ssoProviderHost extracts host from auth URL`() { + assertThat(ssoProviderHost("https://sso.redhat.com/auth/realms/redhat-external/")) + .isEqualTo("sso.redhat.com") + assertThat(ssoProviderHost("https://custom.example.com:8443/auth/")) + .isEqualTo("custom.example.com") + } + + @Test + fun `ssoProviderHost falls back to raw URL when host missing`() { + assertThat(ssoProviderHost("not-a-url")).isEqualTo("not-a-url") + } + + @Test + fun `isSsoUnreachable is true for DNS connection and timeout failures`() { + assertThat(isSsoUnreachable(UnknownHostException("sso.redhat.com"))).isTrue() + assertThat(isSsoUnreachable(ConnectException("Connection refused"))).isTrue() + assertThat(isSsoUnreachable(SocketTimeoutException("Read timed out"))).isTrue() + assertThat(isSsoUnreachable(HttpTimeoutException("request timed out"))).isTrue() + assertThat(isSsoUnreachable(TimeoutException("timed out"))).isTrue() + } + + @Test + fun `isSsoUnreachable walks nested causes`() { + val nested = IOException("send failed", UnknownHostException("sso.redhat.com")) + assertThat(isSsoUnreachable(nested)).isTrue() + } + + @Test + fun `isSsoUnreachable is false for unrelated failures`() { + assertThat(isSsoUnreachable(IllegalStateException("bad metadata"))).isFalse() + assertThat(isSsoUnreachable(IOException("404 Not Found"))).isFalse() + } + + @Test + fun `resolve maps unreachable host to SsoLoginException with Token hint`() { + val resolver = OidcProviderMetadataResolver( + authUrl = "https://oidc-unreachable-host.invalid/auth/realms/test/" + ) + + val error = assertThrows { + runBlocking { resolver.resolve() } + } + + assertThat(error.message).contains("Cannot reach SSO provider (oidc-unreachable-host.invalid)") + assertThat(error.message).contains("Token authentication") + assertThat(error.cause).isNotNull + assertThat(isSsoUnreachable(error.cause!!)).isTrue() + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceTest.kt index a76060e1..dbda52cd 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/devworkspace/DevWorkspaceTest.kt @@ -74,29 +74,28 @@ class DevWorkspaceTest { } @Test - fun `equals returns true for workspaces with same name, namespace, annotations and labels`() { + fun `equals returns true for workspaces with same name and namespace`() { // given - val annotations = mapOf("key1" to "value1") - val labels = mapOf("label1" to "labelValue1") val workspace1 = createDevWorkspace( name = "test-workspace", namespace = "test-ns", - annotations = annotations, - labels = labels + annotations = mapOf("key1" to "value1"), + labels = mapOf("label1" to "labelValue1") ) val workspace2 = createDevWorkspace( name = "test-workspace", namespace = "test-ns", - annotations = annotations, - labels = labels + annotations = mapOf("key1" to "value2"), + labels = mapOf("label1" to "other") ) // when/then assertThat(workspace1).isEqualTo(workspace2) + assertThat(workspace1.hashCode()).isEqualTo(workspace2.hashCode()) } @Test - fun `equals returns false for workspaces with different annotations`() { + fun `equals returns true when annotations differ but name and namespace match`() { // given val workspace1 = createDevWorkspace( name = "test-workspace", @@ -112,11 +111,12 @@ class DevWorkspaceTest { ) // when/then - assertThat(workspace1).isNotEqualTo(workspace2) + assertThat(workspace1).isEqualTo(workspace2) + assertThat(workspace1.hashCode()).isEqualTo(workspace2.hashCode()) } @Test - fun `equals returns false for workspaces with different labels`() { + fun `equals returns true when labels differ but name and namespace match`() { // given val workspace1 = createDevWorkspace( name = "test-workspace", @@ -132,7 +132,8 @@ class DevWorkspaceTest { ) // when/then - assertThat(workspace1).isNotEqualTo(workspace2) + assertThat(workspace1).isEqualTo(workspace2) + assertThat(workspace1.hashCode()).isEqualTo(workspace2.hashCode()) } @Test diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt index c77c9f15..f539322f 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/FileWatcherTest.kt @@ -12,23 +12,27 @@ package com.redhat.devtools.gateway.kubeconfig import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout - - import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test - import org.junit.jupiter.api.io.TempDir import java.nio.file.Path import kotlin.io.path.createFile +import kotlin.io.path.deleteExisting import kotlin.io.path.writeText @ExperimentalCoroutinesApi @@ -57,136 +61,217 @@ class FileWatcherTest { @Test fun `#addFile() invokes callback when a file is added`() = runTest { - // given - var onFileChangedCount = 0 - watcher.onFileChanged { onFileChangedCount++ } + var onFileChangedPath: Path? = null + watcher.onFileChanged { path -> onFileChangedPath = path } - // when watcher.addFile(testFile) advanceUntilIdle() - // then - Initial notification when file is added - assertThat(onFileChangedCount).isEqualTo(1) + assertThat(onFileChangedPath).isEqualTo(testFile) + assertThat(watcher.getMonitoredFiles()).containsExactly(testFile) } @Test - fun `#addFile() does not invoke callback for non-existent files`() = runTest { - // given + fun `#addFile() invokes callback for non-existent files that are tracked`() = runTest { var onFileChangedCount = 0 watcher.onFileChanged { onFileChangedCount++ } val nonExistentFile = tempDir.resolve("non-existent.yaml") - // when watcher.addFile(nonExistentFile) advanceUntilIdle() - // then - No callback should be invoked for non-existent files - assertThat(onFileChangedCount).isEqualTo(0) + assertThat(onFileChangedCount).isEqualTo(1) + assertThat(watcher.getMonitoredFiles()).containsExactly(nonExistentFile) } @Test - fun `#addFile() invokes callback when multiple files are added`() = runTest { - // given + fun `#addFile() invokes callback for each file when multiple are added`() = runTest { var onFileChangedCount = 0 watcher.onFileChanged { onFileChangedCount++ } - val secondFile = createFile("second-file.yaml","second content") + val secondFile = createFile("second-file.yaml", "second content") - // when watcher .addFile(testFile) .addFile(secondFile) advanceUntilIdle() - // then - Callback should be invoked for both files assertThat(onFileChangedCount).isEqualTo(2) + assertThat(watcher.getMonitoredFiles()).containsExactlyInAnyOrder(testFile, secondFile) } @Test - fun `#removeFile() removes file from monitoring`() = runTest { - // given + fun `#addFile() notifies again when re-adding after remove`() = runTest { var onFileChangedCount = 0 watcher.onFileChanged { onFileChangedCount++ } - // when - add file and verify it triggers callback watcher.addFile(testFile) advanceUntilIdle() assertThat(onFileChangedCount).isEqualTo(1) - // when - remove file from monitoring watcher.removeFile(testFile) advanceUntilIdle() + assertThat(watcher.getMonitoredFiles()).isEmpty() + + watcher.addFile(testFile) + advanceUntilIdle() + assertThat(onFileChangedCount).isEqualTo(2) + assertThat(watcher.getMonitoredFiles()).containsExactly(testFile) + } + + @Test + fun `#addFile() does not invoke callback when file is already monitored`() = runTest { + var onFileChangedPath: Path? = null + watcher.onFileChanged { path -> onFileChangedPath = path } + + watcher.addFile(testFile) + advanceUntilIdle() - val initialOnFileChangedCount = onFileChangedCount - - // Re-adding the same file should trigger callback again since it was removed + onFileChangedPath = null watcher.addFile(testFile) advanceUntilIdle() - - assertThat(onFileChangedCount).isEqualTo(initialOnFileChangedCount + 1) + + assertThat(onFileChangedPath).isNull() + assertThat(watcher.getMonitoredFiles()).containsExactly(testFile) } @Test fun `#removeFile() handles non-existent files gracefully`() = runTest { - // given val nonExistentFile = tempDir.resolve("never-added.yaml") - // when - removing a file that was never added val result = watcher.removeFile(nonExistentFile) - // then - should return the watcher instance and not throw any exceptions assertThat(result).isSameAs(watcher) } + @Test + fun `#removeFile() unregisters directory when last file is removed`() = runTest { + watcher.addFile(testFile) + advanceUntilIdle() + assertThat(watcher.getWatchedDirectories()).containsExactly(testFile.parent) + + watcher.removeFile(testFile) + advanceUntilIdle() + + assertThat(watcher.getMonitoredFiles()).isEmpty() + assertThat(watcher.getWatchedDirectories()).isEmpty() + } + + @Test + fun `#removeFile() keeps directory watch while sibling files remain`() = runTest { + val secondFile = createFile("second-file.yaml", "second content") + + watcher.addFile(testFile).addFile(secondFile) + advanceUntilIdle() + assertThat(watcher.getWatchedDirectories()).containsExactly(testFile.parent) + + watcher.removeFile(testFile) + advanceUntilIdle() + + assertThat(watcher.getMonitoredFiles()).containsExactly(secondFile) + assertThat(watcher.getWatchedDirectories()).containsExactly(testFile.parent) + } + + @Test + fun `#addFile() registers a directory watch only once for siblings`() = runTest { + val secondFile = createFile("second-file.yaml", "second content") + + watcher.addFile(testFile).addFile(secondFile) + advanceUntilIdle() + + assertThat(watcher.getWatchedDirectories()).containsExactly(testFile.parent) + } + @Test fun `#removeFile() keeps other files monitored`() = runTest { - // given val secondFile = createFile("second-file.yaml", "second content") val thirdFile = createFile("third-file.yaml", "third content") - var onFileChangedCount = 0 - watcher.onFileChanged { onFileChangedCount++ } - // when - add multiple files watcher .addFile(testFile) .addFile(secondFile) .addFile(thirdFile) advanceUntilIdle() - - // then - all files should trigger callbacks - assertThat(onFileChangedCount).isEqualTo(3) - // when - remove one file + assertThat(watcher.getMonitoredFiles()).containsExactlyInAnyOrder(testFile, secondFile, thirdFile) + watcher.removeFile(secondFile) advanceUntilIdle() - // then - other files should still be monitored - val initialOnFileChangedCount = onFileChangedCount - watcher.addFile(testFile) // Re-adding should not trigger (already added) - watcher.addFile(thirdFile) // Re-adding should not trigger (already added) - watcher.addFile(secondFile) // Adding should trigger (was removed) - advanceUntilIdle() + assertThat(watcher.getMonitoredFiles()).containsExactlyInAnyOrder(testFile, thirdFile) - assertThat(onFileChangedCount).isEqualTo(initialOnFileChangedCount + 1) + watcher.addFile(secondFile) + advanceUntilIdle() + assertThat(watcher.getMonitoredFiles()).containsExactlyInAnyOrder(testFile, secondFile, thirdFile) } @Test - fun `#onFileChanged() is invoked when a watched file is modified`() = runTest { - // given - val callbackReceived = CompletableDeferred() - watcher.onFileChanged { - callbackReceived.complete(Unit) - } + fun `#getMonitoredFiles() includes non-existent files`() = runTest { + val existingFile = createFile("existing.yaml", "content") + val nonExistentFile = tempDir.resolve("never-exists.yaml") - watcher.start() - watcher.addFile(testFile) + watcher.addFile(existingFile) + watcher.addFile(nonExistentFile) advanceUntilIdle() - // when - testFile.writeText("updated content") + assertThat(watcher.getMonitoredFiles()).containsExactlyInAnyOrder(existingFile, nonExistentFile) + } - // then - withTimeout(1000) { - callbackReceived.await() + @Test + fun `#onFileChanged() is invoked when a watched file is modified`() = runBlocking { + val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val ioWatcher = FileWatcher(ioScope, Dispatchers.IO) + try { + val callbackReceived = CompletableDeferred() + var notifyCount = 0 + ioWatcher.onFileChanged { + notifyCount++ + // First notify is from addFile; complete on a subsequent FS event. + if (notifyCount > 1) { + callbackReceived.complete(Unit) + } + } + ioWatcher.start() + ioWatcher.addFile(testFile) + delay(200) + + testFile.writeText("updated content") + + @Suppress("ConvertLongToDuration") + withTimeout(5_000) { + callbackReceived.await() + } + } finally { + ioWatcher.stop() + ioScope.cancel() + } + } + + @Test + fun `#onFileChanged() is invoked when a watched file is deleted`() = runBlocking { + val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val ioWatcher = FileWatcher(ioScope, Dispatchers.IO) + try { + val callbackReceived = CompletableDeferred() + var notifyCount = 0 + ioWatcher.onFileChanged { path -> + notifyCount++ + if (notifyCount > 1) { + callbackReceived.complete(path) + } + } + ioWatcher.start() + ioWatcher.addFile(testFile) + delay(200) + + testFile.deleteExisting() + + @Suppress("ConvertLongToDuration") + withTimeout(5_000) { + assertThat(callbackReceived.await()).isEqualTo(testFile) + } + } finally { + ioWatcher.stop() + ioScope.cancel() } } @@ -196,5 +281,4 @@ class FileWatcherTest { file.writeText(content) return file } - -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt index aea169d6..1038cf0e 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/kubeconfig/KubeConfigMonitorTest.kt @@ -11,16 +11,16 @@ */ package com.redhat.devtools.gateway.kubeconfig -import com.redhat.devtools.gateway.kubeconfig.FileWatcher -import com.redhat.devtools.gateway.kubeconfig.KubeConfigUtils -import com.redhat.devtools.gateway.kubeconfig.KubeConfigMonitor import com.redhat.devtools.gateway.openshift.Cluster import io.mockk.every +import io.mockk.justRun import io.mockk.mockk +import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -35,14 +35,13 @@ import kotlin.io.path.createFile @ExperimentalCoroutinesApi class KubeConfigMonitorTest { - // notsecret — cluster token literals in this class are invented test fixtures only. - @TempDir lateinit var tempDir: Path + private lateinit var testDispatcher: TestDispatcher private lateinit var testScope: TestScope - private lateinit var mockFileWatcher: FileWatcher - private lateinit var mockKubeConfigBuilder: KubeConfigUtils + private lateinit var fileWatcher: FileWatcher + private lateinit var mockKubeConfigUtils: KubeConfigUtils private lateinit var kubeconfigMonitor: KubeConfigMonitor private lateinit var kubeconfigPath1: Path @@ -50,15 +49,18 @@ class KubeConfigMonitorTest { @BeforeEach fun beforeEach() { - // given - testScope = TestScope(StandardTestDispatcher()) - mockFileWatcher = mockk(relaxed = true) - mockKubeConfigBuilder = mockk(relaxed = true) + testDispatcher = StandardTestDispatcher() + testScope = TestScope(testDispatcher) + // Real FileWatcher for path tracking; stub start() so its infinite poll/delay loop + // does not run on the test dispatcher (advanceUntilIdle would never complete). + fileWatcher = spyk(FileWatcher(testScope, testDispatcher)) + justRun { fileWatcher.start() } + mockKubeConfigUtils = mockk(relaxed = true) kubeconfigPath1 = tempDir.resolve("kubeconfig1.yaml").createFile() kubeconfigPath2 = tempDir.resolve("kubeconfig2.yaml").createFile() - kubeconfigMonitor = KubeConfigMonitor(testScope, mockFileWatcher, mockKubeConfigBuilder) + kubeconfigMonitor = KubeConfigMonitor(testScope, fileWatcher, mockKubeConfigUtils) } @AfterEach @@ -67,92 +69,117 @@ class KubeConfigMonitorTest { } @Test - fun `#start should initially parse and publish clusters`() = testScope.runTest { - // given - val cluster1 = Cluster(name = "skywalker", url = "url1", token = null) - every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) - every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) + fun `#start should initially parse and publish clusters`() = runTest(testDispatcher) { + val cluster1 = Cluster(name = "skywalker", url = "url1", token = null) + every { mockKubeConfigUtils.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) + every { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) - // when kubeconfigMonitor.start() advanceUntilIdle() - // then assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1) - verify(exactly = 1) { mockFileWatcher.addFile(kubeconfigPath1) } - verify(exactly = 1) { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } + assertThat(fileWatcher.getMonitoredFiles()).containsExactly(kubeconfigPath1) + // addFile notify + barrier refreshClusters coalesce to one parse of the full set + verify(exactly = 1) { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath1)) } + } + + @Test + fun `#start coalesces multi-file addFile notifies into one full-list parse`() = runTest(testDispatcher) { + val cluster1 = Cluster(name = "skywalker", url = "url1", token = null) + val cluster2 = Cluster(name = "obi-wan", url = "url2", token = null) + + every { mockKubeConfigUtils.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1, kubeconfigPath2) + every { + mockKubeConfigUtils.getClusters(match { it.toSet() == setOf(kubeconfigPath1, kubeconfigPath2) }) + } returns listOf(cluster1, cluster2) + + kubeconfigMonitor.start() + advanceUntilIdle() + + verify(exactly = 0) { mockKubeConfigUtils.getClusters(emptyList()) } + verify(exactly = 0) { + mockKubeConfigUtils.getClusters(match { it.size == 1 }) + } + verify(exactly = 1) { + mockKubeConfigUtils.getClusters(match { it.toSet() == setOf(kubeconfigPath1, kubeconfigPath2) }) + } + assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactlyInAnyOrder(cluster1, cluster2) + assertThat(fileWatcher.getMonitoredFiles()).containsExactlyInAnyOrder(kubeconfigPath1, kubeconfigPath2) } @Test - fun `#onFileChanged should reparse and publish updated clusters`() = testScope.runTest { - // given + fun `#onFileChanged should reparse and publish updated clusters`() = runTest(testDispatcher) { val cluster1 = Cluster(name = "skywalker", url = "url1", token = null) val cluster1Updated = Cluster(name = "skywalker", url = "url1", token = "token1") - every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) - every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) + every { mockKubeConfigUtils.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) + every { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) kubeconfigMonitor.start() advanceUntilIdle() assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1) - // when - every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1Updated) - kubeconfigMonitor.onFileChanged(kubeconfigPath1) // Simulate watcher callback + every { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1Updated) + kubeconfigMonitor.onFileChanged(kubeconfigPath1) advanceUntilIdle() - // then assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1Updated) - verify(exactly = 2) { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } // Initial + update + verify(exactly = 2) { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath1)) } } @Test - fun `#updateMonitoredPaths should add and remove files based on KUBECONFIG env var`() = testScope.runTest { - // given + fun `#refreshClusters skips emit when cluster list is unchanged`() = runTest(testDispatcher) { + val cluster1 = Cluster(name = "skywalker", url = "url1", token = null) + every { mockKubeConfigUtils.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) + every { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) + + kubeconfigMonitor.start() + advanceUntilIdle() + assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1) + + kubeconfigMonitor.refreshClusters() + advanceUntilIdle() + + assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1) + verify(exactly = 2) { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath1)) } + } + + @Test + fun `#updateMonitoredPaths should add and remove files based on KUBECONFIG env var`() = runTest(testDispatcher) { val cluster1 = Cluster(name = "skywalker", url = "url1") val cluster2 = Cluster(name = "obi-wan", url = "url2") - // Initial KUBECONFIG - every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) - every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) + every { mockKubeConfigUtils.getAllConfigFiles(any()) } returns listOf(kubeconfigPath1) + every { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath1)) } returns listOf(cluster1) kubeconfigMonitor.start() advanceUntilIdle() assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster1) - verify(exactly = 1) { mockFileWatcher.addFile(kubeconfigPath1) } + assertThat(fileWatcher.getMonitoredFiles()).containsExactly(kubeconfigPath1) - // when: Change KUBECONFIG to include kubeconfigPath2 and remove kubeconfigPath1 - every { mockKubeConfigBuilder.getAllConfigFiles(any()) } returns listOf(kubeconfigPath2) - every { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath2)) } returns listOf(cluster2) + every { mockKubeConfigUtils.getAllConfigFiles(any()) } returns listOf(kubeconfigPath2) + every { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath2)) } returns listOf(cluster2) - // Manually trigger updateMonitoredPaths and reparse kubeconfigMonitor.updateMonitoredPaths() kubeconfigMonitor.refreshClusters() advanceUntilIdle() - // then assertThat(kubeconfigMonitor.getCurrentClusters()).containsExactly(cluster2) - verify(exactly = 1) { - mockFileWatcher.removeFile(kubeconfigPath1) - mockFileWatcher.addFile(kubeconfigPath2) - } - verify(exactly = 1) { mockKubeConfigBuilder.getClusters(listOf(kubeconfigPath2)) } + assertThat(fileWatcher.getMonitoredFiles()).containsExactly(kubeconfigPath2) + verify(atLeast = 1) { mockKubeConfigUtils.getClusters(listOf(kubeconfigPath2)) } } @Test - fun `#stop should not cancel the provided scope`() = testScope.runTest { - // given + fun `#stop should not cancel the provided scope`() = runTest(testDispatcher) { val mockScope = mockk(relaxed = true) - val monitor = KubeConfigMonitor(mockScope, mockFileWatcher, mockKubeConfigBuilder) + val monitor = KubeConfigMonitor(mockScope, fileWatcher, mockKubeConfigUtils) every { mockScope.cancel() } returns Unit - // when monitor.stop() advanceUntilIdle() - // then verify(exactly = 0) { mockScope.cancel() } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt index 73059c28..6001cd34 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/server/RemoteIDEServerTest.kt @@ -210,7 +210,7 @@ class RemoteIDEServerTest { remoteIDEServer.getStatus() } - // then — exec should never be called, preventing 60s hang on stuck pod + // then — exec should never be called, preventing a long hang on stuck pod assertThat(result).isEqualTo(RemoteIDEServerStatus.empty()) coVerify(exactly = 0) { anyConstructed().exec(any(), any(), any(), any(), any()) diff --git a/src/test/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardViewDisposeTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardViewDisposeTest.kt new file mode 100644 index 00000000..1404cef5 --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/view/DevSpacesWizardViewDisposeTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.view + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DevSpacesWizardViewDisposeTest { + + @Test + fun `canEnableNavigationButtons is false when steps were cleared`() { + assertThat(canEnableNavigationButtons(stepCount = 0, currentStep = 0)).isFalse() + } + + @Test + fun `canEnableNavigationButtons is false when currentStep is out of range`() { + assertThat(canEnableNavigationButtons(stepCount = 2, currentStep = 2)).isFalse() + assertThat(canEnableNavigationButtons(stepCount = 2, currentStep = -1)).isFalse() + } + + @Test + fun `canEnableNavigationButtons is true for a valid step index`() { + assertThat(canEnableNavigationButtons(stepCount = 2, currentStep = 0)).isTrue() + assertThat(canEnableNavigationButtons(stepCount = 2, currentStep = 1)).isTrue() + } +}