diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 1c2e49c..9e78e71 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -33,6 +33,7 @@ kotlin { } commonTest.dependencies { implementation(kotlin("test")) + implementation(libs.kotlinx.coroutinesTest) } } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt index 5c41dcb..5593611 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/platform/DesktopActions.kt @@ -3,6 +3,7 @@ package com.rukira.wowbackup.platform import io.github.oshai.kotlinlogging.KotlinLogging import java.awt.Desktop import java.io.File +import java.net.URI private val logger = KotlinLogging.logger {} @@ -15,4 +16,16 @@ object DesktopActions { logger.error(e) { "Failed to open folder: $path" } } } + + fun openUrl(url: String) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI(url)) + } else { + logger.warn { "Desktop browsing is not supported on this platform" } + } + } catch (e: Exception) { + logger.error(e) { "Failed to open URL: $url" } + } + } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt index 8809df5..02ffa13 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusScreen.kt @@ -2,6 +2,7 @@ package com.rukira.wowbackup.ui.status import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -214,12 +215,33 @@ fun StatusScreen( } Spacer(Modifier.weight(1f)) - Text( - "WoW Backup v${BuildConfig.VERSION}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( modifier = Modifier.fillMaxWidth(), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - ) + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "WoW Backup v${BuildConfig.VERSION}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val update = uiState.updateInfo + if (update != null) { + Spacer(Modifier.width(8.dp)) + Text( + "• Update available (v${update.latestVersion})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.width(6.dp)) + Button( + onClick = { DesktopActions.openUrl(update.releaseUrl) }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), + modifier = Modifier.height(26.dp), + ) { + Text("Download", style = MaterialTheme.typography.labelSmall) + } + } + } } } diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt index ff36ca0..3227a0e 100644 --- a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/ui/status/StatusViewModel.kt @@ -7,13 +7,18 @@ import com.rukira.wowbackup.backup.BackupProgress import com.rukira.wowbackup.backup.BackupResult import com.rukira.wowbackup.backup.BackupScheduler import com.rukira.wowbackup.config.ConfigManager +import com.rukira.wowbackup.update.UpdateChecker +import com.rukira.wowbackup.update.UpdateInfo +import com.rukira.wowbackup.update.UpdateService import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -32,10 +37,25 @@ data class StatusUiState( val backupProgress: BackupProgress? = null, val totalBackups: Int = 0, val backupPath: String? = null, + val updateInfo: UpdateInfo? = null, ) @OptIn(kotlin.time.ExperimentalTime::class) -class StatusViewModel : ViewModel() { +class StatusViewModel( + private val updateService: UpdateService = UpdateChecker(), +) : ViewModel() { + + private val updateInfo = MutableStateFlow(null) + + init { + viewModelScope.launch(Dispatchers.IO) { + try { + updateInfo.value = updateService.checkForUpdate() + } catch (e: Exception) { + // Silently handle any uncaught exception + } + } + } // Only recomputes when backupPath or lastBackupResult changes — not on every progress tick private val totalBackups = combine( @@ -53,7 +73,8 @@ class StatusViewModel : ViewModel() { ConfigManager.config, BackupScheduler.state, totalBackups, - ) { config, scheduler, backupCount -> + updateInfo, + ) { config, scheduler, backupCount, update -> StatusUiState( isConfigured = config.isConfigured, lastBackupTime = scheduler.lastBackupTime?.let { formatRelativeTime(it) }, @@ -66,6 +87,7 @@ class StatusViewModel : ViewModel() { backupProgress = scheduler.currentProgress, totalBackups = backupCount, backupPath = config.backupPath, + updateInfo = update, ) }.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), StatusUiState()) diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt new file mode 100644 index 0000000..27efe6f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/SemVer.kt @@ -0,0 +1,52 @@ +package com.rukira.wowbackup.update + +data class SemVer( + val major: Int, + val minor: Int = 0, + val patch: Int = 0, + val preRelease: String? = null, +) : Comparable { + + override fun compareTo(other: SemVer): Int { + if (this.major != other.major) return this.major.compareTo(other.major) + if (this.minor != other.minor) return this.minor.compareTo(other.minor) + if (this.patch != other.patch) return this.patch.compareTo(other.patch) + + // A version without a pre-release is greater than one with a pre-release + return when { + this.preRelease == null && other.preRelease == null -> 0 + this.preRelease == null && other.preRelease != null -> 1 + this.preRelease != null && other.preRelease == null -> -1 + else -> this.preRelease!!.compareTo(other.preRelease!!) + } + } + + fun isNewerThan(other: SemVer): Boolean = this > other + + override fun toString(): String { + val base = "$major.$minor.$patch" + return if (preRelease != null) "$base-$preRelease" else base + } + + companion object { + fun parseOrNull(version: String?): SemVer? { + if (version.isNullOrBlank()) return null + + val cleaned = version.trim().removePrefix("v").removePrefix("V").trim() + if (cleaned.isEmpty()) return null + + val parts = cleaned.split("-", limit = 2) + val core = parts[0] + val preRelease = parts.getOrNull(1)?.takeIf { it.isNotBlank() } + + val numParts = core.split(".") + if (numParts.isEmpty() || numParts.size > 3) return null + + val major = numParts[0].toIntOrNull() ?: return null + val minor = if (numParts.size > 1) numParts[1].toIntOrNull() ?: return null else 0 + val patch = if (numParts.size > 2) numParts[2].toIntOrNull() ?: return null else 0 + + return SemVer(major = major, minor = minor, patch = patch, preRelease = preRelease) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/UpdateChecker.kt b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/UpdateChecker.kt new file mode 100644 index 0000000..5b8e315 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/rukira/wowbackup/update/UpdateChecker.kt @@ -0,0 +1,102 @@ +package com.rukira.wowbackup.update + +import com.rukira.wowbackup.BuildConfig +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +private val logger = KotlinLogging.logger {} + +@Serializable +internal data class ReleaseDto( + @SerialName("tag_name") val tagName: String = "", + val name: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, +) + +data class UpdateInfo( + val latestVersion: String, + val releaseUrl: String, +) + +interface UpdateService { + suspend fun checkForUpdate(currentVersion: String = BuildConfig.VERSION): UpdateInfo? +} + +class UpdateChecker( + private val apiUrl: String = DEFAULT_API_URL, + private val fallbackUrl: String = DEFAULT_FALLBACK_URL, + private val httpClient: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(), +) : UpdateService { + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) { + try { + val request = HttpRequest.newBuilder() + .uri(URI.create(apiUrl)) + .timeout(Duration.ofSeconds(5)) + .header("Accept", "application/json") + .header("User-Agent", "WoWBackup/${BuildConfig.VERSION}") + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) { + logger.warn { "Update check received non-200 status code: ${response.statusCode()}" } + return@withContext null + } + + val body = response.body() + if (body.isNullOrBlank()) { + logger.warn { "Update check received empty response body" } + return@withContext null + } + + val release = json.decodeFromString(body) + val remoteSemVer = SemVer.parseOrNull(release.tagName) ?: run { + logger.warn { "Failed to parse remote release tag: '${release.tagName}'" } + return@withContext null + } + + val currentSemVer = SemVer.parseOrNull(currentVersion) ?: run { + logger.warn { "Failed to parse current version: '$currentVersion'" } + return@withContext null + } + + if (remoteSemVer.isNewerThan(currentSemVer)) { + val releaseUrl = release.htmlUrl?.takeIf { it.isNotBlank() } ?: fallbackUrl + val cleanTag = release.tagName.trim().removePrefix("v").removePrefix("V") + logger.info { "New update available: v$cleanTag (current: v$currentVersion). URL: $releaseUrl" } + UpdateInfo( + latestVersion = cleanTag, + releaseUrl = releaseUrl, + ) + } else { + logger.debug { "App is up to date: current=$currentVersion, remote=${release.tagName}" } + null + } + } catch (e: Exception) { + logger.warn(e) { "Failed to check for updates: ${e.message}" } + null + } + } + + companion object { + const val DEFAULT_API_URL = "https://git.asarius.site/api/v1/repos/rukira/wow-backup/releases/latest" + const val DEFAULT_FALLBACK_URL = "https://git.asarius.site/rukira/wow-backup/releases" + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/ui/status/StatusViewModelTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/ui/status/StatusViewModelTest.kt new file mode 100644 index 0000000..fe72ba2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/ui/status/StatusViewModelTest.kt @@ -0,0 +1,67 @@ +package com.rukira.wowbackup.ui.status + +import com.rukira.wowbackup.update.UpdateInfo +import com.rukira.wowbackup.update.UpdateService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class StatusViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testViewModelReceivesUpdateInfo() = runTest(testDispatcher) { + val mockService = object : UpdateService { + override suspend fun checkForUpdate(currentVersion: String): UpdateInfo { + return UpdateInfo( + latestVersion = "1.0.2", + releaseUrl = "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", + ) + } + } + + val viewModel = StatusViewModel(updateService = mockService) + advanceUntilIdle() + + val state = viewModel.state.first { it.updateInfo != null } + assertNotNull(state.updateInfo) + assertEquals("1.0.2", state.updateInfo!!.latestVersion) + assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", state.updateInfo!!.releaseUrl) + } + + @Test + fun testViewModelHandlesNoUpdate() = runTest(testDispatcher) { + val mockService = object : UpdateService { + override suspend fun checkForUpdate(currentVersion: String): UpdateInfo? = null + } + + val viewModel = StatusViewModel(updateService = mockService) + advanceUntilIdle() + + val state = viewModel.state.value + assertNull(state.updateInfo) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt new file mode 100644 index 0000000..7032f9d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/SemVerTest.kt @@ -0,0 +1,97 @@ +package com.rukira.wowbackup.update + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SemVerTest { + + @Test + fun testParseStandardVersions() { + val v1 = SemVer.parseOrNull("1.0.0") + assertNotNull(v1) + assertEquals(1, v1.major) + assertEquals(0, v1.minor) + assertEquals(0, v1.patch) + assertNull(v1.preRelease) + + val v2 = SemVer.parseOrNull("v1.2.3") + assertNotNull(v2) + assertEquals(1, v2.major) + assertEquals(2, v2.minor) + assertEquals(3, v2.patch) + assertNull(v2.preRelease) + + val v3 = SemVer.parseOrNull("V2.0") + assertNotNull(v3) + assertEquals(2, v3.major) + assertEquals(0, v3.minor) + assertEquals(0, v3.patch) + + val v4 = SemVer.parseOrNull("0.0.1") + assertNotNull(v4) + assertEquals(0, v4.major) + assertEquals(0, v4.minor) + assertEquals(1, v4.patch) + } + + @Test + fun testParsePreReleaseVersions() { + val v = SemVer.parseOrNull("1.0.0-beta.1") + assertNotNull(v) + assertEquals(1, v.major) + assertEquals(0, v.minor) + assertEquals(0, v.patch) + assertEquals("beta.1", v.preRelease) + } + + @Test + fun testParseInvalidVersions() { + assertNull(SemVer.parseOrNull(null)) + assertNull(SemVer.parseOrNull("")) + assertNull(SemVer.parseOrNull(" ")) + assertNull(SemVer.parseOrNull("invalid")) + assertNull(SemVer.parseOrNull("1.a.3")) + assertNull(SemVer.parseOrNull("1.2.3.4")) + } + + @Test + fun testVersionComparison() { + val v001 = SemVer.parseOrNull("0.0.1")!! + val v100 = SemVer.parseOrNull("1.0.0")!! + val v101 = SemVer.parseOrNull("1.0.1")!! + val v102 = SemVer.parseOrNull("v1.0.2")!! + val v110 = SemVer.parseOrNull("1.1.0")!! + val v200 = SemVer.parseOrNull("2.0.0")!! + + assertTrue(v100.isNewerThan(v001)) + assertTrue(v101.isNewerThan(v100)) + assertTrue(v102.isNewerThan(v101)) + assertTrue(v110.isNewerThan(v102)) + assertTrue(v200.isNewerThan(v110)) + + assertFalse(v101.isNewerThan(v102)) + assertFalse(v102.isNewerThan(SemVer.parseOrNull("1.0.2")!!)) + } + + @Test + fun testPreReleaseComparison() { + val release = SemVer.parseOrNull("1.0.0")!! + val preRelease = SemVer.parseOrNull("1.0.0-alpha")!! + val preReleaseBeta = SemVer.parseOrNull("1.0.0-beta")!! + + assertTrue(release.isNewerThan(preRelease)) + assertFalse(preRelease.isNewerThan(release)) + assertTrue(preReleaseBeta.isNewerThan(preRelease)) + } + + @Test + fun testToString() { + assertEquals("1.0.0", SemVer.parseOrNull("1.0.0")?.toString()) + assertEquals("1.2.0", SemVer.parseOrNull("v1.2")?.toString()) + assertEquals("1.0.0-rc1", SemVer.parseOrNull("1.0.0-rc1")?.toString()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt new file mode 100644 index 0000000..c728994 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/rukira/wowbackup/update/UpdateCheckerTest.kt @@ -0,0 +1,172 @@ +package com.rukira.wowbackup.update + +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import java.net.InetSocketAddress +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class UpdateCheckerTest { + + private lateinit var server: HttpServer + private var serverPort: Int = 0 + + @BeforeTest + fun setUp() { + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + serverPort = server.address.port + server.start() + } + + @AfterTest + fun tearDown() { + server.stop(0) + } + + @Test + fun testUpdateAvailableWhenRemoteIsNewer() = runBlocking { + val jsonResponse = """ + { + "id": 5, + "tag_name": "v1.0.2", + "name": "WoW Backup v1.0.2", + "html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2" + } + """.trimIndent() + + server.createContext("/releases/latest") { exchange -> + exchange.responseHeaders.add("Content-Type", "application/json") + val bytes = jsonResponse.toByteArray() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.write(bytes) + exchange.close() + } + + val checker = UpdateChecker( + apiUrl = "http://127.0.0.1:$serverPort/releases/latest", + fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases", + ) + + val result = checker.checkForUpdate(currentVersion = "1.0.1") + assertNotNull(result) + assertEquals("1.0.2", result.latestVersion) + assertEquals("https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2", result.releaseUrl) + } + + @Test + fun testNoUpdateWhenUpToDate() = runBlocking { + val jsonResponse = """ + { + "id": 5, + "tag_name": "v1.0.2", + "name": "WoW Backup v1.0.2", + "html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2" + } + """.trimIndent() + + server.createContext("/releases/latest") { exchange -> + exchange.responseHeaders.add("Content-Type", "application/json") + val bytes = jsonResponse.toByteArray() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.write(bytes) + exchange.close() + } + + val checker = UpdateChecker( + apiUrl = "http://127.0.0.1:$serverPort/releases/latest", + ) + + val result = checker.checkForUpdate(currentVersion = "1.0.2") + assertNull(result) + } + + @Test + fun testNoUpdateWhenRunningNewerVersion() = runBlocking { + val jsonResponse = """ + { + "id": 5, + "tag_name": "v1.0.2", + "name": "WoW Backup v1.0.2", + "html_url": "https://git.asarius.site/rukira/wow-backup/releases/tag/v1.0.2" + } + """.trimIndent() + + server.createContext("/releases/latest") { exchange -> + exchange.responseHeaders.add("Content-Type", "application/json") + val bytes = jsonResponse.toByteArray() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.write(bytes) + exchange.close() + } + + val checker = UpdateChecker( + apiUrl = "http://127.0.0.1:$serverPort/releases/latest", + ) + + val result = checker.checkForUpdate(currentVersion = "2.0.0") + assertNull(result) + } + + @Test + fun testHttpErrorGracefullyReturnsNull() = runBlocking { + server.createContext("/releases/latest") { exchange -> + exchange.sendResponseHeaders(404, 0) + exchange.close() + } + + val checker = UpdateChecker( + apiUrl = "http://127.0.0.1:$serverPort/releases/latest", + ) + + val result = checker.checkForUpdate(currentVersion = "1.0.0") + assertNull(result) + } + + @Test + fun testMalformedJsonGracefullyReturnsNull() = runBlocking { + server.createContext("/releases/latest") { exchange -> + val bytes = "Not valid JSON".toByteArray() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.write(bytes) + exchange.close() + } + + val checker = UpdateChecker( + apiUrl = "http://127.0.0.1:$serverPort/releases/latest", + ) + + val result = checker.checkForUpdate(currentVersion = "1.0.0") + assertNull(result) + } + + @Test + fun testFallbackUrlUsedWhenHtmlUrlMissing() = runBlocking { + val jsonResponse = """ + { + "id": 5, + "tag_name": "v1.0.2", + "name": "WoW Backup v1.0.2" + } + """.trimIndent() + + server.createContext("/releases/latest") { exchange -> + val bytes = jsonResponse.toByteArray() + exchange.sendResponseHeaders(200, bytes.size.toLong()) + exchange.responseBody.write(bytes) + exchange.close() + } + + val checker = UpdateChecker( + apiUrl = "http://127.0.0.1:$serverPort/releases/latest", + fallbackUrl = "https://git.asarius.site/rukira/wow-backup/releases", + ) + + val result = checker.checkForUpdate(currentVersion = "1.0.0") + assertNotNull(result) + assertEquals("https://git.asarius.site/rukira/wow-backup/releases", result.releaseUrl) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9e629fd..c8a2a2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ materialKolor = "2.0.0" androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } cardiologist = { module = "io.github.kevincianfarini.cardiologist:cardiologist", version.ref = "cardiologist" }