Stop swallowing coroutine errors nobody asserted on
`drainEscapedCoroutineErrors()` cleared the collector at rule-construction time
with `runCatching { runTest {} }`, and discarding what it found was the whole
mechanism: it could not tell the one known deposit from an escaped error nobody
had asserted on. That traded a loud, misleading failure for a silent one, which
was acceptable only while exactly one depositor existed and the seam to remove it
did not.
The seam exists now, so the depositor is gone: the OOM is consumed by the test
that raises it. Every Compose class takes the v2 `createComposeRule()` directly,
and a future escaped error fails a test again instead of disappearing.
The two findings the drain's KDoc carried that outlive it: the v2 rule and the
non-v2 `StateRestorationTester` do interoperate -- the note now sits at the two
declarations that pair them -- and a drain could never have been a `@Before`
(the rule's `runTest` wraps it) or a `@BeforeClass` (Robolectric runs that
outside the sandbox classloader, where the collector is a different object).
Full JVM suite run twice in a row with the drain deleted: 373 tests, 0 failures
both times.
Closes #66
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -41,11 +42,11 @@ import org.robolectric.RobolectricTestRunner
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AppRootRestorationTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors]. Every Compose test
|
||||
// class in this source set starts there, whether or not it is the one that happens to be
|
||||
// running when another test's escaped coroutine error is delivered.
|
||||
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
|
||||
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
|
||||
// deliberate: the v2 package has no tester of its own and the two do interoperate.
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private val restoration = StateRestorationTester(composeRule)
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.libremediaconverter
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
/**
|
||||
* Clears coroutine errors this module's tests deliberately let escape, so they land on the test
|
||||
* that caused them instead of on the next one to start.
|
||||
*
|
||||
* **Every Compose test class in `src/test` has to start here.** `createComposeRule` runs the
|
||||
* composition inside `runTest`, and `runTest` opens by throwing `UncaughtExceptionsBeforeTest` for
|
||||
* anything already sitting in kotlinx-coroutines-test's collector -- a process-wide
|
||||
* `CoroutineExceptionHandler` it installs once and never removes.
|
||||
*
|
||||
* There is one deposit into that collector here, and it is not a mistake:
|
||||
* `ConversionViewModelProbeFailureTest.an OutOfMemoryError is not swallowed` proves an OOM raised
|
||||
* inside the probe is rethrown rather than reported as an unreadable file. `onInputPicked` runs it
|
||||
* in `viewModelScope.launch`, which has no exception handler by design -- the ViewModel's own KDoc
|
||||
* says a real OOM should reach the thread's handler and take the process down. On the JVM the
|
||||
* collector takes it instead, holds it, and hands it to whichever `runTest` starts next.
|
||||
*
|
||||
* It surfaced as two *different* Compose test classes failing on two consecutive runs of the same,
|
||||
* green, code, with a message naming neither the test nor the error's origin. Which class catches
|
||||
* it moves because the throw happens on a real `Dispatchers.IO` thread, after the state assertion
|
||||
* that ends the test that caused it -- so it can be delivered long after that class is done.
|
||||
*
|
||||
* A `@Before` method cannot do this: the compose rule's `runTest` wraps the statement that calls
|
||||
* `@Before`, so it has already thrown. `@BeforeClass` cannot either -- Robolectric runs it outside
|
||||
* the sandbox classloader, where the collector is a different object. Draining while the rule is
|
||||
* being *constructed* is early enough, because JUnit builds a fresh test-class instance, and with
|
||||
* it every `@get:Rule` field, before evaluating any rule.
|
||||
*
|
||||
* The real fix is a seam: give the probe hop an injectable dispatcher the way
|
||||
* `ConversionViewModel`'s constructor already does for `cleanupDispatcher`, and the error would
|
||||
* have somewhere to land. That is a production change, so it belongs in its own commit.
|
||||
*/
|
||||
fun drainEscapedCoroutineErrors() {
|
||||
// Entering a test scope is what flushes the collector; the flush is reported as this
|
||||
// throwing, and there is nothing to assert about an error another test already asserted on.
|
||||
runCatching { runTest {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* [createComposeRule], with [drainEscapedCoroutineErrors] run first. Use this rather than
|
||||
* `createComposeRule` directly in `src/test`.
|
||||
*
|
||||
* It also keeps the one mixed import in one place: the rule comes from the **v2** package
|
||||
* (`androidx.compose.ui.test.junit4.v2`) while `StateRestorationTester`, which takes it, does not.
|
||||
*/
|
||||
fun createDrainedComposeRule() = run {
|
||||
drainEscapedCoroutineErrors()
|
||||
createComposeRule()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
|
||||
import androidx.compose.runtime.saveable.SaveableStateRegistry
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -15,7 +16,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
@@ -57,9 +57,8 @@ import org.robolectric.RobolectricTestRunner
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AdvancedPanelSavedStateTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/**
|
||||
* `canBeSaved = { true }` deliberately.
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
@@ -16,7 +17,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ContainerCapabilities
|
||||
@@ -61,9 +61,11 @@ import org.robolectric.RobolectricTestRunner
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AdvancedPickerTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors].
|
||||
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
|
||||
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
|
||||
// deliberate: the v2 package has no tester of its own and the two do interoperate.
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private val restoration = StateRestorationTester(composeRule)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -9,7 +10,6 @@ import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
@@ -47,7 +47,7 @@ import org.robolectric.RobolectricTestRunner
|
||||
class ConverterLeafTagsTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun assertResolvesToOneNode(tag: String) {
|
||||
composeRule.onAllNodesWithTag(tag).assertCountEquals(1)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -13,7 +14,6 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
@@ -48,7 +48,7 @@ import org.robolectric.RobolectricTestRunner
|
||||
class ConverterPickerSelectionTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/**
|
||||
* The chip carrying [label] inside the row tagged [rowTag].
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
@@ -9,7 +10,6 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -39,9 +39,8 @@ import java.io.File
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterScreenContentTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||
private val savedAs = mutableListOf<String>()
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -15,7 +16,6 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
@@ -72,9 +72,8 @@ import java.io.File
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConverterStateAffordancesTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/**
|
||||
* Every callback the screen fired, in order, tagged with the value it carried.
|
||||
|
||||
@@ -3,13 +3,13 @@ package org.libremediaconverter.convert
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.InputKind
|
||||
@@ -56,7 +56,7 @@ import org.robolectric.RobolectricTestRunner
|
||||
class FileCardTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `a file no provider could measure says so in words rather than showing a zero`() {
|
||||
|
||||
@@ -2,13 +2,13 @@ package org.libremediaconverter.join
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@@ -32,7 +32,7 @@ import org.robolectric.RobolectricTestRunner
|
||||
class JoinLeafTagsTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun input(displayName: String) = InputFile(
|
||||
uri = Uri.parse("content://test/$displayName"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
@@ -8,7 +9,6 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -35,9 +35,8 @@ import java.io.File
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinScreenContentTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/** What the screen asked to save, in the order it asked. Empty until Save is tapped. */
|
||||
private val savedAs = mutableListOf<String>()
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assertRangeInfoEquals
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -17,7 +18,6 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.createDrainedComposeRule
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -60,9 +60,8 @@ import java.io.File
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinStateAffordancesTest {
|
||||
|
||||
// Not `createComposeRule()` directly: see [org.libremediaconverter.drainEscapedCoroutineErrors].
|
||||
@get:Rule
|
||||
val composeRule = createDrainedComposeRule()
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
/** Which callback the screen invoked, in order, with what it passed. Empty until one fires. */
|
||||
private val events = mutableListOf<String>()
|
||||
|
||||
Reference in New Issue
Block a user