diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6dd10d4..460943a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,10 +43,12 @@ jobs: - name: Build Plugin if: ${{ steps.release.outputs.release_created }} run: ./gradlew buildPlugin + timeout-minutes: 20 - name: Verify Plugin if: ${{ steps.release.outputs.release_created }} run: ./gradlew verifyPlugin + timeout-minutes: 20 - name: Upload Build Artifact if: ${{ steps.release.outputs.release_created }} diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index ca86167..b640cf1 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -29,9 +29,11 @@ jobs: - name: Build Plugin run: ./gradlew buildPlugin + timeout-minutes: 20 - name: Run Tests run: ./gradlew test + timeout-minutes: 20 - name: Generate Coverage Report run: ./gradlew koverXmlReport diff --git a/build.gradle.kts b/build.gradle.kts index bf1141e..51b1600 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { } testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) } @@ -99,9 +100,9 @@ intellijPlatform { ides { // Use specific IDE versions that exist instead of recommended() // which may select non-existent future versions - ide(IntelliJPlatformType.IntellijIdeaCommunity, "2024.1.7") - ide(IntelliJPlatformType.IntellijIdeaCommunity, "2024.2.4") - ide(IntelliJPlatformType.IntellijIdeaCommunity, "2024.3.1") + create(IntelliJPlatformType.IntellijIdeaCommunity, "2024.1.7") + create(IntelliJPlatformType.IntellijIdeaCommunity, "2024.2.5") + create(IntelliJPlatformType.IntellijIdeaCommunity, "2024.3.5") } } } @@ -130,7 +131,7 @@ tasks { } wrapper { - gradleVersion = "8.12" + gradleVersion = "9.2.1" } publishPlugin { diff --git a/gradle.properties b/gradle.properties index 004b6e2..1d42f02 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ pluginUntilBuild=253.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin.html#intellij-platform-extension platformType=IC -platformVersion=2024.1 +platformVersion=2024.3.5 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html platformBundledPlugins=Git4Idea diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b501686..73d09aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,21 @@ [versions] # Kotlin -kotlin = "2.1.0" +kotlin = "2.3.0" # Gradle Plugins -changelog = "2.2.1" -intelliJPlatform = "2.2.1" -kover = "0.9.0" +changelog = "2.5.0" +intelliJPlatform = "2.10.5" +kover = "0.9.4" # Libraries -kotlinxSerialization = "1.7.3" +kotlinxSerialization = "1.9.0" kotlinxCoroutines = "1.9.0" toml4j = "0.7.2" snakeyaml = "2.3" # Testing -junit = "5.11.3" -mockk = "1.13.13" +junit = "5.11.4" +mockk = "1.14.0" [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -30,4 +30,5 @@ kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor toml4j = { group = "com.moandjiezana.toml", name = "toml4j", version.ref = "toml4j" } snakeyaml = { group = "org.yaml", name = "snakeyaml", version.ref = "snakeyaml" } junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit" } +junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version = "1.11.4" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a79..23449a2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 057afac..f3b75f3 100755 --- a/gradlew +++ b/gradlew @@ -202,7 +202,7 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, diff --git a/gradlew.bat b/gradlew.bat index 640d686..9b42019 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,94 +1,94 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/version/VersionChecker.kt b/src/main/kotlin/xyz/shelltime/jetbrains/version/VersionChecker.kt index 5f6d2a9..c135f22 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/version/VersionChecker.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/version/VersionChecker.kt @@ -1,8 +1,10 @@ package xyz.shelltime.jetbrains.version -import com.intellij.ide.BrowserUtil +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.Project import kotlinx.coroutines.* import kotlinx.serialization.json.Json @@ -90,11 +92,8 @@ class VersionChecker( "$message

Run: $updateCommand", NotificationType.WARNING ) - .addAction(object : com.intellij.notification.NotificationAction("Copy Update Command") { - override fun actionPerformed( - e: com.intellij.notification.AnActionEvent, - notification: com.intellij.notification.Notification - ) { + .addAction(object : NotificationAction("Copy Update Command") { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { val clipboard = java.awt.Toolkit.getDefaultToolkit().systemClipboard clipboard.setContents(java.awt.datatransfer.StringSelection(updateCommand), null) notification.expire() diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt index dab018e..c59100f 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt @@ -116,4 +116,48 @@ class HeartbeatDataTest { assertNull(response.platform) assertNull(response.goVersion) } + + @Test + fun `VersionCheckResponse deserializes correctly with update available`() { + val jsonString = """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""" + val response = json.decodeFromString(jsonString) + + assertFalse(response.isLatest) + assertEquals("2.0.0", response.latestVersion) + assertEquals("1.0.0", response.version) + } + + @Test + fun `VersionCheckResponse deserializes correctly when up to date`() { + val jsonString = """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""" + val response = json.decodeFromString(jsonString) + + assertTrue(response.isLatest) + assertEquals("1.0.0", response.latestVersion) + assertEquals("1.0.0", response.version) + } + + @Test + fun `VersionCheckResponse serializes correctly`() { + val response = VersionCheckResponse(isLatest = false, latestVersion = "2.0.0", version = "1.0.0") + val jsonString = json.encodeToString(response) + + // Deserialize back to verify correct JSON structure + val deserialized = json.decodeFromString(jsonString) + assertEquals(response, deserialized) + assertFalse(deserialized.isLatest) + assertEquals("2.0.0", deserialized.latestVersion) + assertEquals("1.0.0", deserialized.version) + } + + @Test + fun `VersionCheckResponse ignores unknown fields`() { + val jsonString = """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0","unknownField":"value"}""" + val response = json.decodeFromString(jsonString) + + // Verify unknown field was ignored and known fields were parsed correctly + assertTrue(response.isLatest) + assertEquals("1.0.0", response.latestVersion) + assertEquals("1.0.0", response.version) + } } diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt new file mode 100644 index 0000000..161c23a --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt @@ -0,0 +1,195 @@ +package xyz.shelltime.jetbrains.version + +import io.mockk.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import java.io.ByteArrayInputStream +import java.net.HttpURLConnection +import java.net.URL + +class VersionCheckerTest { + + @BeforeEach + fun setup() { + mockkConstructor(URL::class) + } + + @AfterEach + fun teardown() { + unmockkAll() + } + + @Test + fun `dispose cancels coroutine scope without errors`() { + val checker = VersionChecker("https://api.test.com", "https://web.test.com") + assertDoesNotThrow { checker.dispose() } + } + + @Test + fun `checkVersion handles connection failure gracefully`() = runBlocking { + every { anyConstructed().openConnection() } throws java.io.IOException("Connection failed") + + val checker = VersionChecker("https://api.test.com", "https://web.test.com") + + // Should not throw - version check failures are silent + assertDoesNotThrow { checker.checkVersion("1.0.0", null) } + + // Allow time for coroutine to execute + delay(200) + + // Verify the connection was attempted + verify { anyConstructed().openConnection() } + + checker.dispose() + } + + @Test + fun `checkVersion handles HTTP error response gracefully`() = runBlocking { + val mockConnection = mockk(relaxed = true) + every { anyConstructed().openConnection() } returns mockConnection + every { mockConnection.responseCode } returns 500 + + val checker = VersionChecker("https://api.test.com", "https://web.test.com") + + // Should not throw - HTTP errors are handled gracefully + assertDoesNotThrow { checker.checkVersion("1.0.0", null) } + + // Allow time for coroutine to execute + delay(200) + + // Verify HTTP error was handled + verify { mockConnection.responseCode } + verify { mockConnection.disconnect() } + + checker.dispose() + } + + @Test + fun `checkVersion shows notification when update is available`() = runBlocking { + val mockConnection = mockk(relaxed = true) + every { anyConstructed().openConnection() } returns mockConnection + every { mockConnection.responseCode } returns 200 + every { mockConnection.inputStream } returns ByteArrayInputStream( + """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""".toByteArray() + ) + + val checker = VersionChecker("https://api.test.com", "https://web.test.com") + + assertDoesNotThrow { checker.checkVersion("1.0.0", null) } + + // Allow time for coroutine to execute + delay(200) + + // Verify the connection was made and response was read + verify { mockConnection.responseCode } + verify { mockConnection.inputStream } + verify { mockConnection.disconnect() } + + checker.dispose() + } + + @Test + fun `checkVersion does not show notification when version is up to date`() = runBlocking { + val mockConnection = mockk(relaxed = true) + every { anyConstructed().openConnection() } returns mockConnection + every { mockConnection.responseCode } returns 200 + every { mockConnection.inputStream } returns ByteArrayInputStream( + """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""".toByteArray() + ) + + val checker = VersionChecker("https://api.test.com", "https://web.test.com") + + assertDoesNotThrow { checker.checkVersion("1.0.0", null) } + + // Allow time for coroutine to execute + delay(200) + + // Verify the connection was made and response was read + verify { mockConnection.responseCode } + verify { mockConnection.inputStream } + verify { mockConnection.disconnect() } + + checker.dispose() + } + + @Test + fun `version check encodes special characters in version`() = runBlocking { + val mockConnection = mockk(relaxed = true) + val mockInputStream = mockk(relaxed = true) + val mockBufferedReader = mockk(relaxed = true) + + every { anyConstructed().openConnection() } returns mockConnection + every { mockConnection.responseCode } returns 200 + every { mockConnection.inputStream } returns ByteArrayInputStream( + """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""".toByteArray() + ) + + val checker = VersionChecker("https://api.test.com", "https://web.test.com") + + // Version with special characters should be URL-encoded + assertDoesNotThrow { checker.checkVersion("1.0.0-beta+build.123", null) } + + // Allow time for coroutine to execute + delay(200) + + // Verify the connection was made (URL encoding happens in the URL construction) + verify { anyConstructed().openConnection() } + verify { mockConnection.disconnect() } + + checker.dispose() + } + + @Test + fun `checkVersion handles malformed JSON response gracefully`() = runBlocking { + val mockConnection = mockk(relaxed = true) + every { anyConstructed().openConnection() } returns mockConnection + every { mockConnection.responseCode } returns 200 + every { mockConnection.inputStream } returns ByteArrayInputStream( + """{"invalid json""".toByteArray() + ) + + val checker = VersionChecker("https://api.test.com", "https://web.test.com") + + // Should not throw - JSON parse errors are handled gracefully + assertDoesNotThrow { checker.checkVersion("1.0.0", null) } + + // Allow time for coroutine to execute + delay(200) + + // Verify the connection was attempted and response was read + verify { mockConnection.responseCode } + verify { mockConnection.inputStream } + verify { mockConnection.disconnect() } + + checker.dispose() + } + + @Test + fun `hasShownWarning flag prevents multiple notifications`() = runBlocking { + val mockConnection = mockk(relaxed = true) + every { anyConstructed().openConnection() } returns mockConnection + every { mockConnection.responseCode } returns 200 + every { mockConnection.inputStream } returns ByteArrayInputStream( + """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""".toByteArray() + ) + + val checker = VersionChecker("https://api.test.com", "https://web.test.com") + + // First call should trigger check + checker.checkVersion("1.0.0", null) + delay(200) + + // Second call should be skipped + checker.checkVersion("1.0.0", null) + delay(200) + + // Should only open connection once + verify(exactly = 1) { anyConstructed().openConnection() } + + checker.dispose() + } +}