From d635f3b9a09a7c466a6827f743d2e02ec2efaf5c Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:46:32 +0000 Subject: [PATCH] cover lms and nextcloud music in the integration suite Run the integration harness from its own devshell so the server packages stay out of the everyday shell. Add lms and a Nextcloud Music instance to the harness and extend the suite with auth-fallback, media, error, write round-trip, multi-source and prune tests. The auth test caught that lms answers OpenSubsonic error 42 (not 41) when it refuses token auth, so the client now falls back on both codes. --- AGENTS.md | 9 +- app/build.gradle.kts | 8 + .../subsonic/SubsonicAuthIntegrationTest.kt | 39 ++++ .../subsonic/SubsonicErrorIntegrationTest.kt | 42 ++++ .../subsonic/SubsonicMediaIntegrationTest.kt | 96 +++++++++ .../subsonic/SubsonicSourceIntegrationTest.kt | 27 +-- .../subsonic/SubsonicWriteIntegrationTest.kt | 144 ++++++++++++++ .../data/source/subsonic/TestServers.kt | 50 +++++ .../sync/MultiSourceSyncIntegrationTest.kt | 54 +++++ .../data/sync/PruneSyncIntegrationTest.kt | 98 +++++++++ .../data/sync/SyncServiceIntegrationTest.kt | 33 +-- .../data/source/subsonic/SubsonicClient.kt | 7 +- .../source/subsonic/SubsonicClientTest.kt | 15 ++ flake.lock | 19 +- flake.nix | 66 ++++-- tools/integration-test.nu | 188 ++++++++++++++++-- 16 files changed, 802 insertions(+), 93 deletions(-) create mode 100644 app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicAuthIntegrationTest.kt create mode 100644 app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicErrorIntegrationTest.kt create mode 100644 app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicMediaIntegrationTest.kt create mode 100644 app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicWriteIntegrationTest.kt create mode 100644 app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/TestServers.kt create mode 100644 app/src/integrationTest/kotlin/com/subtracks/data/sync/MultiSourceSyncIntegrationTest.kt create mode 100644 app/src/integrationTest/kotlin/com/subtracks/data/sync/PruneSyncIntegrationTest.kt diff --git a/AGENTS.md b/AGENTS.md index 234b109c..37916475 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,8 @@ A native Android rewrite of Subtracks, a client for Subsonic-compatible servers Everything comes from the Nix flake devshell; do not install toolchains by hand. - Enter it with `nix develop`, or let direnv load it into your shell (`.envrc` runs `use flake`). -- The devshell provides JDK 21, Gradle 9, the Android SDK (platform 37, build-tools 37.0.0, platform-tools 37.0.1), nushell, navidrome and gonic. +- The default devshell provides JDK 21, Gradle 9, the Android SDK (platform 37, build-tools 37.0.0, platform-tools 37.0.1) and nushell. +- The integration harness needs real servers, so it lives in a separate `nix develop .#integration` shell (navidrome, gonic, lms, php, Nextcloud with the Music app, sqlite). Keeping them out of the default shell avoids pulling that closure into everyday builds. - Build with the devshell `gradle`, not `./gradlew`. CI uses the nix Gradle pinned by `flake.lock`; the wrapper is kept only for people without Nix. ## Commands @@ -21,7 +22,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand. - Unit tests: `gradle :app:testDebugUnitTest` - Lint: `gradle :app:ktlintCheck :app:lintDebug` - Format: `gradle :app:ktlintFormat` -- Integration tests: `./tools/integration-test.nu` (starts navidrome and gonic, then runs `:app:integrationTest`) +- Integration tests: `nix develop .#integration --command nu tools/integration-test.nu` (starts navidrome, gonic, lms and a Nextcloud Music instance, then runs `:app:integrationTest`) - Screenshots: `gradle :app:recordRoborazziDebug` renders the `*Screen` composables to `app/src/test/screenshots/` (gitignored) plus an HTML report in `app/build/reports/roborazzi/`, for local review. No golden images are committed and `verifyRoborazziDebug` is not part of CI. ## Testing layout @@ -57,5 +58,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand. - Robolectric creates a fresh `Application` per test in one JVM, so `SubtracksApp.onCreate` stops any running Koin before `startKoin`. - `applicationId` is `com.subtracks.next` for the beta; change it to `com.subtracks` before any store release. - The CI image provides `nix-ld`, `jq`, `sqlite`, `node`, `zstd` and a `runner` user that `cache-nix-action` expects. None of that is needed locally beyond the devshell. -- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`). The test music is cached in `.integration/music` (gitignored). +- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`), lms on 5082 (`admin`/`subtracks-lms`), Nextcloud Music on 8090 (`admin`/`subtracks-nextcloud`, base URL `http://localhost:8090/index.php/apps/music/subsonic/`). The test music is cached in `.integration/music` (gitignored). +- lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys**, not passwords or the `t`/`s` token scheme (both reject token auth with error 41). The harness seeds an API key and the integration tests use `useTokenAuth = false`, so the app's token-auth fallback is currently not exercised by integration tests. +- The integration shell takes its server packages from a pinned stable nixpkgs (`nixpkgs-stable`), because lms 3.80 in nixpkgs-unstable segfaults at startup with wt 4.14.3. - `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 19cf7124..8dbc1cc3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -84,7 +84,13 @@ aboutLibraries { val integrationTestClasses = listOf( + "com.subtracks.data.source.subsonic.SubsonicAuthIntegrationTest", + "com.subtracks.data.source.subsonic.SubsonicErrorIntegrationTest", + "com.subtracks.data.source.subsonic.SubsonicMediaIntegrationTest", "com.subtracks.data.source.subsonic.SubsonicSourceIntegrationTest", + "com.subtracks.data.source.subsonic.SubsonicWriteIntegrationTest", + "com.subtracks.data.sync.MultiSourceSyncIntegrationTest", + "com.subtracks.data.sync.PruneSyncIntegrationTest", "com.subtracks.data.sync.SyncServiceIntegrationTest", ) @@ -106,6 +112,8 @@ tasks.register("integrationTest") { filter { integrationTestClasses.forEach { includeTestsMatching(it) } } + systemProperty("prune.baseUrl", providers.gradleProperty("pruneBaseUrl").getOrElse("")) + systemProperty("prune.musicDir", providers.gradleProperty("pruneMusicDir").getOrElse("")) } dependencies { diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicAuthIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicAuthIntegrationTest.kt new file mode 100644 index 00000000..54c5a345 --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicAuthIntegrationTest.kt @@ -0,0 +1,39 @@ +package com.subtracks.data.source.subsonic + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class SubsonicAuthIntegrationTest( + private val server: TestServer, +) { + @Test + fun tokenAuthFallsBackToPlaintextWhenTheServerRejectsIt() = + runBlocking { + var fallbacks = 0 + val source = SubsonicSource(1, TestServers.client(server, useTokenAuth = true) { fallbacks++ }) + + source.ping() + + assertEquals("$server: fallback fired once", !server.supportsTokenAuth, fallbacks == 1) + + source.ping() + + assertEquals("$server: fallback is not retried", if (server.supportsTokenAuth) 0 else 1, fallbacks) + } + + @Test + fun plaintextAuthPings() = + runBlocking { + TestServers.source(server, useTokenAuth = false).ping() + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun servers(): List> = TestServers.all.map { arrayOf(it) } + } +} diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicErrorIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicErrorIntegrationTest.kt new file mode 100644 index 00000000..378fb76c --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicErrorIntegrationTest.kt @@ -0,0 +1,42 @@ +package com.subtracks.data.source.subsonic + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class SubsonicErrorIntegrationTest( + private val server: TestServer, +) { + @Test + fun wrongPasswordThrowsCode40() = + runBlocking { + val bad = server.copy(password = "${server.password}-wrong") + val source = SubsonicSource(1, TestServers.client(bad)) + + val failure = runCatching { source.ping() } + + val error = failure.exceptionOrNull() + assertTrue("$server: $failure", error is SubsonicException) + assertEquals("$server", 40, (error as SubsonicException).code) + } + + @Test + fun unknownMethodThrows() = + runBlocking { + val client = TestServers.client(server) + + val failure = runCatching { client.check("definitelyNotASubsonicMethod") } + + assertTrue("$server: $failure", failure.exceptionOrNull() is SubsonicException) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun servers(): List> = TestServers.all.map { arrayOf(it) } + } +} diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicMediaIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicMediaIntegrationTest.kt new file mode 100644 index 00000000..4840eb80 --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicMediaIntegrationTest.kt @@ -0,0 +1,96 @@ +package com.subtracks.data.source.subsonic + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class SubsonicMediaIntegrationTest( + private val server: TestServer, +) { + private val http = OkHttpClient() + + @Test + fun streamsOriginalAudio() = + runBlocking { + val source = TestServers.source(server) + val song = + source + .songs() + .toList() + .flatten() + .first() + + get(source.streamUri(song.id).toString()).use { response -> + assertTrue("$server: HTTP ${response.code}", response.isSuccessful) + val bytes = response.body.bytes() + assertTrue("$server: ${bytes.size} bytes", bytes.size > 1000) + assertFalse("$server: stream returned a text body", bytes.startsWithText()) + } + } + + @Test + fun downloadsOriginalAudio() = + runBlocking { + val source = TestServers.source(server) + val song = + source + .songs() + .toList() + .flatten() + .first() + + get(source.downloadUri(song.id).toString()).use { response -> + assertTrue("$server: HTTP ${response.code}", response.isSuccessful) + val bytes = response.body.bytes() + assertTrue("$server: ${bytes.size} bytes", bytes.size > 1000) + } + } + + @Test + fun fetchesCoverArtImage() = + runBlocking { + val source = TestServers.source(server) + val album = + source + .albums() + .toList() + .flatten() + .first { it.coverArt != null } + + get(source.coverArtUri(album.coverArt)!!.toString()).use { response -> + assertTrue("$server: HTTP ${response.code}", response.isSuccessful) + val bytes = response.body.bytes() + assertTrue("$server: ${bytes.size} bytes", bytes.size > 100) + assertTrue("$server: not a JPEG or PNG", bytes.isJpegOrPng()) + } + } + + private fun get(url: String): Response = http.newCall(Request.Builder().url(url).build()).execute() + + private fun ByteArray.startsWithText(): Boolean = decodeToString(0, minOf(8, size)).trimStart().startsWith("<") + + private fun ByteArray.isJpegOrPng(): Boolean { + val jpeg = size >= 2 && this[0] == 0xFF.toByte() && this[1] == 0xD8.toByte() + val png = + size >= 4 && + this[0] == 0x89.toByte() && + this[1] == 0x50.toByte() && + this[2] == 0x4E.toByte() && + this[3] == 0x47.toByte() + return jpeg || png + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun servers(): List> = TestServers.all.map { arrayOf(it) } + } +} diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt index 9e93ea04..95a0024c 100644 --- a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt +++ b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceIntegrationTest.kt @@ -3,8 +3,6 @@ package com.subtracks.data.source.subsonic import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -16,21 +14,13 @@ import org.junit.runners.Parameterized @RunWith(Parameterized::class) class SubsonicSourceIntegrationTest( - private val server: Server, + private val server: TestServer, ) { private lateinit var source: SubsonicSource @Before fun setUp() { - val client = - SubsonicClient( - baseUrl = server.baseUrl.toHttpUrl(), - username = server.username, - password = server.password, - useTokenAuth = false, - http = OkHttpClient(), - ) - source = SubsonicSource(1, client) + source = TestServers.source(server) } @Test @@ -107,20 +97,9 @@ class SubsonicSourceIntegrationTest( private suspend fun Flow>.collectAll(): List = toList().flatten() - data class Server( - val name: String, - val baseUrl: String, - val username: String, - val password: String, - ) - companion object { @JvmStatic @Parameterized.Parameters(name = "{0}") - fun servers(): List> = - listOf( - arrayOf(Server("navidrome", "http://localhost:4533/", "admin", "password")), - arrayOf(Server("gonic", "http://localhost:4747/", "admin", "admin")), - ) + fun servers(): List> = TestServers.all.map { arrayOf(it) } } } diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicWriteIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicWriteIntegrationTest.kt new file mode 100644 index 00000000..aaf88689 --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/SubsonicWriteIntegrationTest.kt @@ -0,0 +1,144 @@ +package com.subtracks.data.source.subsonic + +import com.subtracks.data.source.StarType +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class SubsonicWriteIntegrationTest( + private val server: TestServer, +) { + @Test + fun starAndUnstarAlbumRoundTrips() = + runBlocking { + val source = TestServers.source(server) + val album = + source + .albums() + .toList() + .flatten() + .first { it.name == "Retroconnaissance EP" } + + source.setStar(StarType.Album, album.id, true) + try { + assertNotNull( + "$server", + source + .albums() + .toList() + .flatten() + .first { it.id == album.id } + .starred, + ) + } finally { + source.setStar(StarType.Album, album.id, false) + } + assertNull( + "$server", + source + .albums() + .toList() + .flatten() + .first { it.id == album.id } + .starred, + ) + } + + @Test + fun starAndUnstarArtistRoundTrips() = + runBlocking { + val source = TestServers.source(server) + val artist = + source + .artists() + .toList() + .flatten() + .first { it.name == "Brad Sucks" } + + source.setStar(StarType.Artist, artist.id, true) + try { + assertNotNull( + "$server", + source + .artists() + .toList() + .flatten() + .first { it.id == artist.id } + .starred, + ) + } finally { + source.setStar(StarType.Artist, artist.id, false) + } + assertNull( + "$server", + source + .artists() + .toList() + .flatten() + .first { it.id == artist.id } + .starred, + ) + } + + @Test + fun starAndUnstarSongRoundTrips() = + runBlocking { + val source = TestServers.source(server) + val song = + source + .songs() + .toList() + .flatten() + .first { it.album == "Retroconnaissance EP" } + + source.setStar(StarType.Song, song.id, true) + try { + assertNotNull( + "$server", + source + .songs() + .toList() + .flatten() + .first { it.id == song.id } + .starred, + ) + } finally { + source.setStar(StarType.Song, song.id, false) + } + assertNull( + "$server", + source + .songs() + .toList() + .flatten() + .first { it.id == song.id } + .starred, + ) + } + + @Test + fun scrobbleAcceptsNowPlayingAndSubmission() = + runBlocking { + val source = TestServers.source(server) + val song = + source + .songs() + .toList() + .flatten() + .first() + + source.scrobble(song.id, submission = false) + source.scrobble(song.id, submission = true, time = System.currentTimeMillis()) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun servers(): List> = TestServers.all.map { arrayOf(it) } + } +} diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/TestServers.kt b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/TestServers.kt new file mode 100644 index 00000000..afa9e0ad --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/source/subsonic/TestServers.kt @@ -0,0 +1,50 @@ +package com.subtracks.data.source.subsonic + +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient + +data class TestServer( + val name: String, + val baseUrl: String, + val username: String, + val password: String, + val supportsTokenAuth: Boolean, +) { + override fun toString(): String = name +} + +object TestServers { + val all = + listOf( + TestServer("navidrome", "http://localhost:4533/", "admin", "password", supportsTokenAuth = true), + TestServer("gonic", "http://localhost:4747/", "admin", "admin", supportsTokenAuth = true), + TestServer("lms", "http://localhost:5082/", "admin", "subtracks-lms", supportsTokenAuth = false), + TestServer( + "nextcloud", + "http://localhost:8090/index.php/apps/music/subsonic/", + "admin", + "subtracks-nextcloud", + supportsTokenAuth = false, + ), + ) + + fun client( + server: TestServer, + useTokenAuth: Boolean = false, + onTokenAuthUnsupported: () -> Unit = {}, + ): SubsonicClient = + SubsonicClient( + baseUrl = server.baseUrl.toHttpUrl(), + username = server.username, + password = server.password, + useTokenAuth = useTokenAuth, + http = OkHttpClient(), + onTokenAuthUnsupported = onTokenAuthUnsupported, + ) + + fun source( + server: TestServer, + id: Long = 1, + useTokenAuth: Boolean = false, + ): SubsonicSource = SubsonicSource(id, client(server, useTokenAuth)) +} diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/sync/MultiSourceSyncIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/sync/MultiSourceSyncIntegrationTest.kt new file mode 100644 index 00000000..12553d60 --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/sync/MultiSourceSyncIntegrationTest.kt @@ -0,0 +1,54 @@ +package com.subtracks.data.sync + +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.Source +import com.subtracks.data.source.subsonic.TestServers +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MultiSourceSyncIntegrationTest { + @Test + fun syncsEveryServerIntoOneLibraryWithoutInterference() = + runBlocking { + val context = ApplicationProvider.getApplicationContext() + val db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + try { + TestServers.all.forEachIndexed { index, server -> + val id = index + 1L + db.sourcesDao().upsertSource( + Source(id = id, name = server.name, address = server.baseUrl, isActive = id == 1L, createdAt = 0), + ) + SyncService(db, TestServers.source(server, id = id)).sync() + } + + val library = db.libraryDao() + TestServers.all.forEachIndexed { index, server -> + val id = index + 1L + assertEquals(server.name, 2, library.artistIds(id).size) + assertEquals(server.name, 3, library.albumIds(id).size) + assertEquals(server.name, 20, library.songIdsAfter(id, "", 1000).size) + assertEquals(server.name, 1, library.playlistIds(id).size) + } + + SyncService(db, TestServers.source(TestServers.all.first(), id = 1)).sync() + + TestServers.all.drop(1).forEachIndexed { index, server -> + assertEquals("$server survived a resync of another source", 3, library.albumIds(index + 2L).size) + } + } finally { + db.close() + } + } +} diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/sync/PruneSyncIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/sync/PruneSyncIntegrationTest.kt new file mode 100644 index 00000000..4ccef057 --- /dev/null +++ b/app/src/integrationTest/kotlin/com/subtracks/data/sync/PruneSyncIntegrationTest.kt @@ -0,0 +1,98 @@ +package com.subtracks.data.sync + +import android.content.Context +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.Source +import com.subtracks.data.source.subsonic.SubsonicClient +import com.subtracks.data.source.subsonic.SubsonicSource +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +class PruneSyncIntegrationTest { + @Test + fun resyncPrunesAlbumsAndSongsRemovedFromTheServer() = + runBlocking { + val baseUrl = requireProperty("prune.baseUrl") + val musicDir = requireProperty("prune.musicDir") + val removed = File(musicDir, REMOVED_ALBUM_DIR) + check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" } + + val context = ApplicationProvider.getApplicationContext() + val db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + try { + db.sourcesDao().upsertSource( + Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0), + ) + val source = + SubsonicSource( + 1, + SubsonicClient( + baseUrl = baseUrl.toHttpUrl(), + username = "admin", + password = "password", + useTokenAuth = false, + http = OkHttpClient(), + ), + ) + + SyncService(db, source).sync() + val library = db.libraryDao() + assertEquals(1, library.artistIds(1).size) + assertEquals(2, library.albumIds(1).size) + assertEquals(8, library.songIdsAfter(1, "", 1000).size) + + removed.deleteRecursively() + rescan(baseUrl) + SyncService(db, source).sync() + + assertEquals(1, library.artistIds(1).size) + assertEquals(1, library.albumIds(1).size) + assertEquals(4, library.songIdsAfter(1, "", 1000).size) + } finally { + db.close() + } + } + + private fun rescan(baseUrl: String) { + val http = OkHttpClient() + val root = baseUrl.trimEnd('/') + + fun call( + method: String, + extra: String = "", + ): String { + val url = "$root/rest/$method.view?u=admin&p=password&v=1.16.1&c=subtracks-test&f=json$extra" + return http.newCall(Request.Builder().url(url).build()).execute().use { it.body.string() } + } + + call("startScan") + repeat(120) { + if (!call("getScanStatus").contains("\"scanning\":true")) return + Thread.sleep(500) + } + error("the prune server did not finish rescanning") + } + + private fun requireProperty(name: String): String = + System.getProperty(name)?.takeIf { it.isNotBlank() } + ?: error("$name is not set; run the integration harness (tools/integration-test.nu)") + + private companion object { + const val REMOVED_ALBUM_DIR = "197" + } +} diff --git a/app/src/integrationTest/kotlin/com/subtracks/data/sync/SyncServiceIntegrationTest.kt b/app/src/integrationTest/kotlin/com/subtracks/data/sync/SyncServiceIntegrationTest.kt index 300358c5..49a203f1 100644 --- a/app/src/integrationTest/kotlin/com/subtracks/data/sync/SyncServiceIntegrationTest.kt +++ b/app/src/integrationTest/kotlin/com/subtracks/data/sync/SyncServiceIntegrationTest.kt @@ -7,11 +7,8 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.Source -import com.subtracks.data.source.subsonic.SubsonicClient -import com.subtracks.data.source.subsonic.SubsonicSource +import com.subtracks.data.source.subsonic.TestServers import kotlinx.coroutines.runBlocking -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -32,20 +29,7 @@ class SyncServiceIntegrationTest { db.sourcesDao().upsertSource( Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0), ) - val source = - SubsonicSource( - id = 1, - client = - SubsonicClient( - baseUrl = server.baseUrl.toHttpUrl(), - username = server.username, - password = server.password, - useTokenAuth = false, - http = OkHttpClient(), - ), - ) - - SyncService(db, source).sync() + SyncService(db, TestServers.source(server)).sync() val library = db.libraryDao() assertEquals(server.name, 2, library.artistIds(1).size) @@ -58,18 +42,7 @@ class SyncServiceIntegrationTest { } } - private data class Server( - val name: String, - val baseUrl: String, - val username: String, - val password: String, - ) - private companion object { - val servers = - listOf( - Server("navidrome", "http://localhost:4533/", "admin", "password"), - Server("gonic", "http://localhost:4747/", "admin", "admin"), - ) + val servers = TestServers.all } } diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt index 7cae8979..31672819 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicClient.kt @@ -58,7 +58,7 @@ class SubsonicClient( return try { execute(method, params, body) } catch (failure: SubsonicException) { - if (failure.code != TOKEN_AUTH_UNSUPPORTED || !usedTokenAuth) throw failure + if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) throw failure disableTokenAuth() execute(method, params, body) } @@ -95,7 +95,10 @@ class SubsonicClient( // stays at the highest minor they accept. OpenSubsonic fields do not depend on it. const val API_VERSION = "1.15.0" const val CLIENT = "subtracks" - const val TOKEN_AUTH_UNSUPPORTED = 41 + + // 41 is the classic Subsonic code for "token authentication not supported"; OpenSubsonic + // servers (lms) answer 42 instead, so both must disable token auth and retry. + val TOKEN_AUTH_UNSUPPORTED_CODES = setOf(41, 42) private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("") diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt index 39134436..db43ce3c 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicClientTest.kt @@ -148,6 +148,21 @@ class SubsonicClientTest { assertEquals(1, notified) } + @Test + fun openSubsonicAuthUnsupportedCodeAlsoRetriesWithPassword() { + server.enqueue(MockResponse().setBody(failed(42, "Provided authentication mechanism not supported."))) + server.enqueue(MockResponse().setBody("")) + var notified = 0 + + client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }).check("ping") + + val first = server.takeRequest().requestUrl!! + assertNotNull(first.queryParameter("t")) + val second = server.takeRequest().requestUrl!! + assertEquals("secret", second.queryParameter("p")) + assertEquals(1, notified) + } + @Test fun tokenAuthUnsupportedIsNotifiedOnceAndNotRetriedAgain() { server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users."))) diff --git a/flake.lock b/flake.lock index 51dc2107..8438086d 100644 --- a/flake.lock +++ b/flake.lock @@ -16,9 +16,26 @@ "type": "github" } }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1790635395, + "narHash": "sha256-bNyvoIyOCu7lzoCpWKWGIsHpTtERUWzqYWFPlN++WTw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7fc6f2c20af09cdcaf48b92ec3121860139ec668", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nixpkgs-stable": "nixpkgs-stable" } } }, diff --git a/flake.nix b/flake.nix index f6d3f081..96d59532 100644 --- a/flake.nix +++ b/flake.nix @@ -3,10 +3,18 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + # The integration shell runs real servers; pin them to a stable release. lms 3.80 (the + # version in unstable) segfaults at startup with wt 4.14.3, so the server packages come from + # here instead. The default shell never touches this input. + nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-26.05"; }; outputs = - { nixpkgs, ... }: + { + nixpkgs, + nixpkgs-stable, + ... + }: let systems = [ "x86_64-linux" ]; forEachSystem = nixpkgs.lib.genAttrs systems; @@ -22,6 +30,7 @@ allowUnfree = true; }; }; + servers = import nixpkgs-stable { inherit system; }; android = pkgs.androidenv.composeAndroidPackages { platformVersions = [ "37" ]; buildToolsVersions = [ "37.0.0" ]; @@ -33,30 +42,51 @@ includeCmake = false; }; androidSdk = android.androidsdk; + + basePackages = [ + pkgs.jdk21 + pkgs.gradle_9 + pkgs.git + pkgs.curl + pkgs.unzip + pkgs.nushell + androidSdk + android.platform-tools + ]; + + shellHook = '' + echo "subtracks dev shell" + echo " java: $(java -version 2>&1 | head -n1)" + echo " ANDROID_HOME: $ANDROID_HOME" + ''; in { default = pkgs.mkShell { - packages = [ - pkgs.jdk21 - pkgs.gradle_9 - pkgs.git - pkgs.curl - pkgs.unzip - pkgs.nushell - pkgs.navidrome - pkgs.gonic - androidSdk - android.platform-tools - ]; + packages = basePackages; ANDROID_HOME = "${androidSdk}/libexec/android-sdk"; ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk"; - shellHook = '' - echo "subtracks dev shell" - echo " java: $(java -version 2>&1 | head -n1)" - echo " ANDROID_HOME: $ANDROID_HOME" - ''; + inherit shellHook; + }; + + # The integration harness starts real Subsonic servers, none of which the app build or + # unit tests need. Kept out of the default shell so those stay free of the server deps. + integration = pkgs.mkShell { + packages = basePackages ++ [ + servers.navidrome + servers.gonic + servers.lms + servers.sqlite + servers.php83 + ]; + + ANDROID_HOME = "${androidSdk}/libexec/android-sdk"; + ANDROID_SDK_ROOT = "${androidSdk}/libexec/android-sdk"; + NEXTCLOUD_DIR = "${servers.nextcloud34}"; + NEXTCLOUD_MUSIC_APP = "${servers.nextcloud34Packages.apps.music}"; + + inherit shellHook; }; } ); diff --git a/tools/integration-test.nu b/tools/integration-test.nu index 26239813..312be0f7 100755 --- a/tools/integration-test.nu +++ b/tools/integration-test.nu @@ -2,6 +2,11 @@ const NAV_PORT = 4533 const GONIC_PORT = 4747 +const LMS_PORT = 5082 +const NC_PORT = 8090 +const PRUNE_PORT = 4534 +const LMS_KEY = "subtracks-lms" +const NC_KEY = "subtracks-nextcloud" const LIB = ".integration/music" const MUSIC = [ { id: 197, name: "Ugress - Retroconnaissance EP", songs: 4 } @@ -30,9 +35,24 @@ def ensure-music [] { rm -rf $tmp } +# Nextcloud's Music app reads repeated (array) parameters straight from the raw query string +# without URL-decoding them, so ids must be sent with their unreserved characters intact. +def url-escape [value: string] { + $value + | url encode + | str replace --all '%2D' '-' + | str replace --all '%2d' '-' + | str replace --all '%2E' '.' + | str replace --all '%2e' '.' + | str replace --all '%5F' '_' + | str replace --all '%5f' '_' + | str replace --all '%7E' '~' + | str replace --all '%7e' '~' +} + def sub-url [base: string, user: string, pass: string, method: string, params: list] { - let all = ([["u" $user] ["p" $pass] ["v" "1.13.0"] ["c" "subtracks-test"] ["f" "json"]] | append $params) - let query = ($all | each {|p| $"($p.0)=($p.1 | url encode)" } | str join "&") + let all = ([["u" $user] ["p" $pass] ["v" "1.16.1"] ["c" "subtracks-test"] ["f" "json"]] | append $params) + let query = ($all | each {|p| $"($p.0)=(url-escape ($p.1 | into string))" } | str join "&") $"($base)rest/($method).view?($query)" } @@ -44,8 +64,8 @@ def sub-get [base: string, user: string, pass: string, method: string, params: l $response } -def poll [label: string, check: closure] { - for attempt in 0..<120 { +def poll [label: string, check: closure, --attempts (-n): int = 120] { + for attempt in 0..<$attempts { let value = (try { do $check } catch { null }) if $value != null { return $value } if $attempt > 0 and ($attempt mod 5) == 0 { print $"waiting for ($label)..." } @@ -116,18 +136,137 @@ def setup-data [base: string, user: string, pass: string] { sleep 1sec sub-get $base $user $pass "scrobble" [["id" (song-id $base $user $pass $kosmo 1)] ["submission" "true"]] | ignore - poll "scrobbled album" {|| - let recent = (sub-get $base $user $pass "getAlbumList2" [["type" "recent"]] | get albumList2.album) - let frequent = (sub-get $base $user $pass "getAlbumList2" [["type" "frequent"]] | get albumList2.album) - if (($recent | any {|a| $a.id == $kosmo }) or ($frequent | any {|a| $a.id == $kosmo })) { $recent } else { null } - } | ignore + # Not every server maintains "recent"/"frequent" album lists; when they are missing the + # scrobble still succeeded, so this is only a best-effort check. + let observed = (try { + poll -n 15 "scrobbled album" {|| + let recent = (sub-get $base $user $pass "getAlbumList2" [["type" "recent"]] | get albumList2.album) + let frequent = (sub-get $base $user $pass "getAlbumList2" [["type" "frequent"]] | get albumList2.album) + if (($recent | any {|a| $a.id == $kosmo }) or ($frequent | any {|a| $a.id == $kosmo })) { $recent } else { null } + } + true + } catch { false }) + if not $observed { print " (no recent/frequent album list on this server; skipping scrobble check)" } +} + +def start-lms [work: string] { + let lms_bin = (which lms | get 0.path) + let lms_home = ($lms_bin | path dirname | path dirname) + let dir = ($work | path join "lms") + mkdir ($dir | path join "work") + let conf = ($dir | path join "lms.conf") + (open ($lms_home | path join "share/lms/lms.conf")) + | str replace --all 'working-dir = "/var/lms"' $"working-dir = \"($dir)/work\"" + | str replace --all 'listen-addr = "0.0.0.0"' 'listen-addr = "127.0.0.1"' + | save -f $conf + + # The first start creates and migrates the database; stop it before seeding, then run for real. + let bootstrap = (job spawn { ^$lms_bin $conf out+err> ($dir | path join "bootstrap.log") }) + let ready = (try { + poll -n 60 "lms database" {|| + try { http get -m 2sec $"http://127.0.0.1:($LMS_PORT)/rest/ping.view?u=x&p=x&v=1.16.0&c=subtracks-test" | ignore; true } catch { null } + } + true + } catch { false }) + job kill $bootstrap + if not $ready { error make { msg: "lms failed to initialise its database" } } + + let db = ($dir | path join "work/lms.db") + let user_sql = (r#'INSERT INTO user (version,type,login_name,bcrypt_round_count,password_salt,password_hash,subsonic_enable_transcoding_by_default,subsonic_default_transcode_format,subsonic_default_transcode_bitrate,subsonic_artist_list_mode,ui_theme,ui_artist_release_sort_method,ui_enable_inline_artist_relationships,ui_inline_artist_relationships,feedback_backend,scrobbling_backend,listenbrainz_token,lastfm_api_key,lastfm_api_secret,lastfm_session_key) VALUES (1,1,'admin',7,'salt','$2y$07$R6aUyqKU1HM4uX6dtpnh0OIsgqa5JID8qaH4syPlFokL3WoATtYim',0,2,128000,0,1,7,0,0,0,0,'','','','');'#) + ^sqlite3 $db $user_sql + let lib_sql = (r#'INSERT INTO media_library (version,path,name) VALUES (0,'MUSIC','Main');'# | str replace 'MUSIC' ((pwd) | path join $LIB)) + ^sqlite3 $db $lib_sql + let token_sql = (r#'INSERT INTO auth_token (version,domain,value,expiry,use_count,last_used,max_use_count,user_id) VALUES (1,'subsonic','KEY',NULL,0,NULL,NULL,(SELECT id FROM user WHERE login_name='admin'));'# | str replace 'KEY' $LMS_KEY) + ^sqlite3 $db $token_sql + + let daemon = (job spawn { ^$lms_bin $conf out+err> ($dir | path join "lms.log") }) + let started = (try { + poll "lms" {|| + let response = (try { sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "ping" } catch { null }) + if $response == null { null } else { true } + } + sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "startScan" | ignore + poll "lms scan" {|| + let scanning = (sub-get $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY "getScanStatus" | get scanStatus.scanning) + if (($scanning | into string | str lowercase) == "false") { true } else { null } + } + true + } catch { false }) + if not $started { do -i { job kill $daemon }; error make { msg: "lms did not become ready" } } + $daemon +} + +def start-nextcloud [work: string] { + let dir = ($work | path join "nextcloud") + mkdir $dir + ^cp -r $"($env.NEXTCLOUD_DIR)/." $dir + ^chmod -R u+w $dir + mkdir ($dir | path join "custom_apps") + ^cp -r $env.NEXTCLOUD_MUSIC_APP ($dir | path join "custom_apps/music") + ^chmod -R u+w ($dir | path join "custom_apps/music") + + let occ = ($dir | path join "occ") + ^php -d "memory_limit=512M" $occ "maintenance:install" "--database" "sqlite" "--admin-user" "admin" "--admin-pass" "password" "--data-dir" ($dir | path join "data") out+err> ($dir | path join "install.log") + ^php -d "memory_limit=512M" $occ "app:enable" "music" out+err> ($dir | path join "enable.log") + mkdir ($dir | path join "data/admin/files/Music") + ^cp -r $"((pwd) | path join $LIB)/." ($dir | path join "data/admin/files/Music") + ^php -d "memory_limit=512M" $occ "files:scan" "admin" out+err> ($dir | path join "files-scan.log") + ^php -d "memory_limit=512M" $occ "music:scan" "admin" out+err> ($dir | path join "music-scan.log") + + let hash = ($NC_KEY | hash sha256) + let key_sql = (r#'INSERT INTO oc_music_ampache_users (user_id, hash, description) VALUES ('admin','HASH','subtracks');'# | str replace 'HASH' $hash) + ^sqlite3 ($dir | path join "data/owncloud.db") $key_sql + + let daemon = (job spawn { ^php -d "memory_limit=512M" -S $"127.0.0.1:($NC_PORT)" -t $dir out+err> ($dir | path join "php.log") }) + let started = (try { + poll "nextcloud" {|| + try { http get -m 3sec $"http://127.0.0.1:($NC_PORT)/status.php" | ignore; true } catch { null } + } + true + } catch { false }) + if not $started { do -i { job kill $daemon }; error make { msg: "nextcloud did not become ready" } } + $daemon +} + +# A second, disposable navidrome over a private copy of two albums. The prune test deletes one +# of them and rescans, so this must not share a library with the servers above. +def start-prune [work: string] { + let dir = ($work | path join "prune") + let music = ($dir | path join "music") + mkdir ($dir | path join "data") + mkdir $music + ^cp -r $"((pwd) | path join $LIB)/197" $music + ^cp -r $"((pwd) | path join $LIB)/199" $music + + let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $PRUNE_PORT --musicfolder $music --datafolder ($dir | path join "data") --loglevel warn out+err> ($dir | path join "navidrome.log") }) + let ready = (try { + poll "prune navidrome" {|| + try { http get -m 2sec $"http://127.0.0.1:($PRUNE_PORT)/rest/ping.view?u=x&p=x&v=1.16.1&c=test" | ignore; true } catch { null } + } + http post -m 10sec -t application/json $"http://127.0.0.1:($PRUNE_PORT)/auth/createAdmin" { username: "admin", password: "password" } | ignore + poll "prune library" {|| + let albums = (try { + http get -m 5sec $"http://127.0.0.1:($PRUNE_PORT)/rest/getAlbumList2.view?u=admin&p=password&v=1.16.1&c=test&f=json&type=newest&size=500" + | get subsonic-response.albumList2.album + } catch { [] }) + if (($albums | length) == 2) { true } else { null } + } + true + } catch { false }) + if not $ready { do -i { job kill $daemon }; error make { msg: "prune navidrome did not become ready" } } + $daemon } def main [] { + if (($env.NEXTCLOUD_DIR? | default "") | is-empty) { + error make { msg: "integration servers are not on PATH; enter the integration shell with 'nix develop .#integration'" } + } + ensure-music - if (port-in-use $NAV_PORT) { error make { msg: $"port ($NAV_PORT) is already in use (leftover navidrome?); stop it and retry" } } - if (port-in-use $GONIC_PORT) { error make { msg: $"port ($GONIC_PORT) is already in use (leftover gonic?); stop it and retry" } } + for port in [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT] { + if (port-in-use $port) { error make { msg: $"port ($port) is already in use; stop the leftover server and retry" } } + } let work = (^mktemp -d | str trim) for dir in ["navidrome" "gonic-cache" "gonic-playlists" "gonic-podcasts"] { @@ -135,11 +274,20 @@ def main [] { } print "starting navidrome" - let nav = (job spawn { ^navidrome --address 127.0.0.1 --port $NAV_PORT --musicfolder $LIB --datafolder $"($work)/navidrome" --loglevel warn }) + let nav = (job spawn { ^navidrome --address 127.0.0.1 --port $NAV_PORT --musicfolder $LIB --datafolder $"($work)/navidrome" --loglevel warn out+err> $"($work)/navidrome.log" }) print "starting gonic" - let gonic = (job spawn { ^gonic -music-path $LIB -listen-addr $"127.0.0.1:($GONIC_PORT)" -db-path $"($work)/gonic.db" -cache-path $"($work)/gonic-cache" -playlists-path $"($work)/gonic-playlists" -podcast-path $"($work)/gonic-podcasts" -scan-at-start-enabled -http-log=false }) - + let gonic = (job spawn { ^gonic -music-path $LIB -listen-addr $"127.0.0.1:($GONIC_PORT)" -db-path $"($work)/gonic.db" -cache-path $"($work)/gonic-cache" -playlists-path $"($work)/gonic-playlists" -podcast-path $"($work)/gonic-podcasts" -scan-at-start-enabled -http-log=false out+err> $"($work)/gonic.log" }) + mut lms = -1 + mut nc = -1 + mut prune = -1 let outcome = (try { + print "starting lms" + $lms = (start-lms $work) + print "starting nextcloud" + $nc = (start-nextcloud $work) + print "starting prune navidrome" + $prune = (start-prune $work) + poll "navidrome" {|| http get -m 5sec $"http://127.0.0.1:($NAV_PORT)/rest/ping.view?u=x&p=x&v=1.13.0&c=test" | ignore true @@ -155,17 +303,27 @@ def main [] { print "setting up test data" setup-data $"http://127.0.0.1:($NAV_PORT)/" "admin" "password" setup-data $"http://127.0.0.1:($GONIC_PORT)/" "admin" "admin" + setup-data $"http://127.0.0.1:($LMS_PORT)/" "admin" $LMS_KEY + setup-data $"http://127.0.0.1:($NC_PORT)/index.php/apps/music/subsonic/" "admin" $NC_KEY print "running integration tests" - ^gradle :app:integrationTest --rerun --no-configuration-cache --console=plain + let prune_base = $"http://localhost:($PRUNE_PORT)/" + let prune_music = ($work | path join "prune/music") + ^gradle :app:integrationTest --rerun --no-configuration-cache --console=plain $"-PpruneBaseUrl=($prune_base)" $"-PpruneMusicDir=($prune_music)" $env.LAST_EXIT_CODE } catch {|e| print $"error: ($e.msg)" ($env.LAST_EXIT_CODE? | default 1) }) + let lms_id = $lms + let nc_id = $nc + let prune_id = $prune do -i { job kill $nav } do -i { job kill $gonic } + if $lms_id >= 0 { do -i { job kill $lms_id } } + if $nc_id >= 0 { do -i { job kill $nc_id } } + if $prune_id >= 0 { do -i { job kill $prune_id } } rm -rf $work exit $outcome