From 4f99b4284436063d02921c5b93095394d4f0996f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 31 Jul 2026 20:39:54 -0400 Subject: [PATCH] ci(android): print full assertion messages for failing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI run 3161 reported seven failures as bare "java.lang.AssertionError at UpdateVeilControllerTest.kt:87" — and line 87 is the test's own `fun ... = runTest {` line, not the assertion. Gradle picks the first stack frame belonging to the test class, and assertions inside a `runTest { }` lambda live in a generated suspend-lambda class that gets filtered out, so every failure in a coroutine test collapses to the function declaration. With the HTML report unreachable from CI, that leaves nothing to debug from. testLogging with exceptionFormat = FULL prints the assertion message and the whole stack trace for failures, which is what makes a coroutine-test failure diagnosable at all here. Also drop the NonCancellable floor-join from UpdateVeilController's finally. Honouring the minimum hold while the scope is being torn down is pointless — nothing is left to render the veil — and a finally that suspends is a finally that can resist cancellation. The floor is now awaited in the try instead. Co-Authored-By: Claude Opus 5 (1M context) --- android/app/build.gradle.kts | 15 ++++++++++++++- .../minstrel/shared/UpdateVeilController.kt | 14 ++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6a283978..192ed4be 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -210,4 +210,17 @@ dependencies { debugImplementation(libs.compose.ui.test.manifest) } -tasks.withType { useJUnitPlatform() } +tasks.withType { + useJUnitPlatform() + // Print the assertion message + full stack trace for failures. The + // default console output gives only "AssertionError at Foo.kt:12", and + // for a failure inside a `runTest { }` lambda even that line collapses + // to the test function's own line (the assertion frames live in the + // suspend-lambda class, which Gradle filters out) — leaving nothing to + // debug from when the HTML report isn't reachable, as in CI. + testLogging { + events("failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + showStackTraces = true + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/UpdateVeilController.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/UpdateVeilController.kt index 83166e54..2d1fabd8 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/UpdateVeilController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/UpdateVeilController.kt @@ -3,7 +3,6 @@ package com.fabledsword.minstrel.shared import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -14,7 +13,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull // Once raised, the veil stays up at least this long. Without a floor a @@ -166,15 +164,15 @@ class UpdateVeilController( // here before the raiser has been dispatched, tear the session // down, and leave the churn uncovered. withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() } + // Honour the no-flash minimum before lowering. Deliberately in + // the try and not the finally: on cancellation the scope is + // going away and nothing will render the veil, so the floor is + // pointless there — and a finally that suspends is a finally + // that can resist teardown. + if (raised.isCompleted) floor.join() } finally { raiser.cancel() ceiling.cancel() - if (raised.isCompleted) { - // NonCancellable so the floor is honoured (and the veil - // always cleared) even while the scope is torn down; the - // floor job dies with the scope, so this cannot hang. - withContext(NonCancellable) { floor.join() } - } floor.cancel() visibleInternal.value = false }