tidy the integration harness and its comments

Trim the explanatory comments to short quirk notes, drop the module headers, and fail cleanly: navidrome/gonic now kill their process if they never become ready and the entrypoint always tears down. The prune test waits for the removal to appear in the album list rather than for the scan flag. Correct the AGENTS token-auth note (lms answers 42, Nextcloud 41) and give CI more headroom.
This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent f79f26fed8
commit 592c522244
8 changed files with 39 additions and 55 deletions
+1 -1
View File
@@ -59,6 +59,6 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
- `applicationId` is `com.subtracks.next` for the beta; change it to `com.subtracks` before any store release. - `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. - 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`), 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). - 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. - lms and Nextcloud Music authenticate the Subsonic API with per-user **API keys** instead of passwords. The harness seeds a key and most tests use `useTokenAuth = false`; `SubsonicAuthIntegrationTest` drives `useTokenAuth = true` and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
- 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. - 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. - `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.
@@ -82,10 +82,10 @@ class PruneSyncIntegrationTest {
call("startScan") call("startScan")
repeat(120) { repeat(120) {
if (!call("getScanStatus").contains("\"scanning\":true")) return if (!call("getAlbumList2", "&type=newest&size=500").contains(REMOVED_ALBUM_NAME)) return
Thread.sleep(500) Thread.sleep(500)
} }
error("the prune server did not finish rescanning") error("the prune server still lists $REMOVED_ALBUM_NAME after rescanning")
} }
private fun requireProperty(name: String): String = private fun requireProperty(name: String): String =
@@ -94,5 +94,6 @@ class PruneSyncIntegrationTest {
private companion object { private companion object {
const val REMOVED_ALBUM_DIR = "197" const val REMOVED_ALBUM_DIR = "197"
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
} }
} }
@@ -96,8 +96,7 @@ class SubsonicClient(
const val API_VERSION = "1.15.0" const val API_VERSION = "1.15.0"
const val CLIENT = "subtracks" const val CLIENT = "subtracks"
// 41 is the classic Subsonic code for "token authentication not supported"; OpenSubsonic // Classic Subsonic uses 41; OpenSubsonic servers (lms) answer 42, so both disable token auth.
// servers (lms) answer 42 instead, so both must disable token auth and retry.
val TOKEN_AUTH_UNSUPPORTED_CODES = setOf(41, 42) val TOKEN_AUTH_UNSUPPORTED_CODES = setOf(41, 42)
private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("") private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("")
+1 -5
View File
@@ -3,9 +3,7 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
# The integration shell runs real servers; pin them to a stable release. lms 3.80 (the # lms 3.80 (unstable) segfaults at startup with wt 4.14.3; stable ships a working 3.78.
# 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"; nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-26.05";
}; };
@@ -70,8 +68,6 @@
inherit shellHook; 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 { integration = pkgs.mkShell {
packages = basePackages ++ [ packages = basePackages ++ [
servers.navidrome servers.navidrome
+10 -16
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env nu #!/usr/bin/env nu
# Starts the test servers, prepares the fixture data the Kotlin integration tests expect, runs # Run inside the integration shell:
# them, then tears everything down. Enter the integration shell first:
#
# nix develop .#integration --command nu tools/integration-test.nu # nix develop .#integration --command nu tools/integration-test.nu
use ./integration/util.nu [port-in-use] use ./integration/util.nu [port-in-use]
@@ -21,15 +19,16 @@ def main [] {
let work = (^mktemp -d | str trim) let work = (^mktemp -d | str trim)
print "starting navidrome" mut nav = -1
let nav = (start-navidrome $work) mut gonic = -1
print "starting gonic"
let gonic = (start-gonic $work)
mut lms = -1 mut lms = -1
mut nc = -1 mut nc = -1
mut prune = -1 mut prune = -1
let outcome = (try { let outcome = (try {
print "starting navidrome"
$nav = (start-navidrome $work)
print "starting gonic"
$gonic = (start-gonic $work)
print "starting lms" print "starting lms"
$lms = (start-lms $work) $lms = (start-lms $work)
print "starting nextcloud" print "starting nextcloud"
@@ -50,14 +49,9 @@ def main [] {
($env.LAST_EXIT_CODE? | default 1) ($env.LAST_EXIT_CODE? | default 1)
}) })
let lms_id = $lms for id in [$nav $gonic $lms $nc $prune] {
let nc_id = $nc if $id >= 0 { do -i { job kill $id } }
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 rm -rf $work
exit $outcome exit $outcome
+19 -16
View File
@@ -1,5 +1,3 @@
# Test servers: fixtures, lifecycle and the credentials the tests rely on.
use ./util.nu * use ./util.nu *
use ./subsonic.nu * use ./subsonic.nu *
@@ -19,8 +17,7 @@ const MUSIC = [
{ id: 321, name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 } { id: 321, name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
] ]
# The servers the Kotlin integration tests and the fixtures below share. Keep this in step with # Keep in step with TestServers.kt.
# TestServers.kt.
export def test-servers [] { export def test-servers [] {
[ [
{ name: "navidrome", base: $"http://localhost:($NAV_PORT)/", user: "admin", pass: "password" } { name: "navidrome", base: $"http://localhost:($NAV_PORT)/", user: "admin", pass: "password" }
@@ -55,10 +52,14 @@ export def start-navidrome [work: string] {
let data = ($work | path join "navidrome") let data = ($work | path join "navidrome")
mkdir $data mkdir $data
let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $NAV_PORT --musicfolder $LIB --datafolder $data --loglevel warn out+err> ($work | path join "navidrome.log") }) let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $NAV_PORT --musicfolder $LIB --datafolder $data --loglevel warn out+err> ($work | path join "navidrome.log") })
poll "navidrome" {|| let ready = (try {
try { 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 } catch { null } poll "navidrome" {||
} try { 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 } catch { null }
http post -m 10sec -t application/json $"http://127.0.0.1:($NAV_PORT)/auth/createAdmin" { username: "admin", password: "password" } | ignore }
http post -m 10sec -t application/json $"http://127.0.0.1:($NAV_PORT)/auth/createAdmin" { username: "admin", password: "password" } | ignore
true
} catch { false })
if not $ready { do -i { job kill $daemon }; error make { msg: "navidrome did not become ready" } }
$daemon $daemon
} }
@@ -67,9 +68,13 @@ export def start-gonic [work: string] {
let daemon = (job spawn { let daemon = (job spawn {
^gonic -music-path $LIB -listen-addr $"127.0.0.1:($GONIC_PORT)" -db-path ($work | path join "gonic.db") -cache-path ($work | path join "gonic-cache") -playlists-path ($work | path join "gonic-playlists") -podcast-path ($work | path join "gonic-podcasts") -scan-at-start-enabled -http-log=false out+err> ($work | path join "gonic.log") ^gonic -music-path $LIB -listen-addr $"127.0.0.1:($GONIC_PORT)" -db-path ($work | path join "gonic.db") -cache-path ($work | path join "gonic-cache") -playlists-path ($work | path join "gonic-playlists") -podcast-path ($work | path join "gonic-podcasts") -scan-at-start-enabled -http-log=false out+err> ($work | path join "gonic.log")
}) })
poll "gonic" {|| let ready = (try {
try { http get -m 5sec $"http://127.0.0.1:($GONIC_PORT)/rest/ping.view?u=x&p=x&v=1.13.0&c=test" | ignore; true } catch { null } poll "gonic" {||
} try { http get -m 5sec $"http://127.0.0.1:($GONIC_PORT)/rest/ping.view?u=x&p=x&v=1.13.0&c=test" | ignore; true } catch { null }
}
true
} catch { false })
if not $ready { do -i { job kill $daemon }; error make { msg: "gonic did not become ready" } }
$daemon $daemon
} }
@@ -84,7 +89,7 @@ export def start-lms [work: string] {
| str replace --all 'listen-addr = "0.0.0.0"' 'listen-addr = "127.0.0.1"' | str replace --all 'listen-addr = "0.0.0.0"' 'listen-addr = "127.0.0.1"'
| save -f $conf | save -f $conf
# The first start creates and migrates the database; stop it before seeding, then run for real. # Seed after the first start has migrated the schema, then run for real.
let bootstrap = (job spawn { ^$lms_bin $conf out+err> ($dir | path join "bootstrap.log") }) let bootstrap = (job spawn { ^$lms_bin $conf out+err> ($dir | path join "bootstrap.log") })
let ready = (try { let ready = (try {
poll -n 60 "lms database" {|| poll -n 60 "lms database" {||
@@ -152,8 +157,7 @@ export def start-nextcloud [work: string] {
$daemon $daemon
} }
# A second, disposable navidrome over a private copy of two albums. The prune test deletes one # The prune test deletes an album from this private copy and rescans, so it must not be shared.
# of them and rescans, so this must not share a library with the servers above.
export def start-prune [work: string] { export def start-prune [work: string] {
let dir = ($work | path join "prune") let dir = ($work | path join "prune")
let music = ($dir | path join "music") let music = ($dir | path join "music")
@@ -181,8 +185,7 @@ export def start-prune [work: string] {
$daemon $daemon
} }
# Wait for a server to index the fixture music, then star an album and artist and add a playlist # Wait for the library, then leave the star/playlist/scrobble state the Kotlin tests assert on.
# and scrobbles. The Kotlin integration tests assert against this state.
export def prepare-fixtures [server: record] { export def prepare-fixtures [server: record] {
let base = $server.base let base = $server.base
let user = $server.user let user = $server.user
+4 -11
View File
@@ -1,21 +1,14 @@
# Thin Subsonic API client used to inspect and prepare the test servers. # Nextcloud reads repeated (array) params from the raw query string without URL-decoding, so ids
# must keep their unreserved characters.
# Nextcloud's Music app reads repeated (array) parameters straight from the raw query string def url-escape [value: string] {
# without URL-decoding them, so ids must be sent with their unreserved characters intact.
export def url-escape [value: string] {
$value $value
| url encode | url encode
| str replace --all '%2D' '-' | 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 '%5f' '_'
| str replace --all '%7E' '~' | str replace --all '%7E' '~'
| str replace --all '%7e' '~'
} }
export def sub-url [base: string, user: string, pass: string, method: string, params: list<any>] { def sub-url [base: string, user: string, pass: string, method: string, params: list<any>] {
let all = ([["u" $user] ["p" $pass] ["v" "1.16.1"] ["c" "subtracks-test"] ["f" "json"]] | append $params) 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 "&") let query = ($all | each {|p| $"($p.0)=(url-escape ($p.1 | into string))" } | str join "&")
$"($base)rest/($method).view?($query)" $"($base)rest/($method).view?($query)"
-2
View File
@@ -1,5 +1,3 @@
# Shared helpers for the integration harness: waiting and port checks.
export def poll [label: string, check: closure, --attempts (-n): int = 120] { export def poll [label: string, check: closure, --attempts (-n): int = 120] {
for attempt in 0..<$attempts { for attempt in 0..<$attempts {
let value = (try { do $check } catch { null }) let value = (try { do $check } catch { null })