Robolectric builds an Application per test class that asks for one, and each onCreate launched a staging sweep on Dispatchers.IO over the shared <cacheDir>/conversions/. Nothing joined them, so a test asserting about a staged file was racing every sweep the classes before it had left in flight (#159). It was CI-only until wave 4 added ten Robolectric classes, at which point OutputPublisherStagingTest started failing locally too. LibreMediaConverterApp gains a protected open sweepScope and publishes the Job onCreate started; the JVM suite substitutes TestLibreMediaConverterApp, whose scope is Dispatchers.Unconfined so the sweep -- a plain function that never suspends -- runs to completion inline. The SupervisorJob is kept so this differs from production in the dispatcher alone. Suite-wide rather than per-test: 27 of the 58 Robolectric classes touch that directory, so opt-in was not a real option. It costs one assertion, knowingly. AppStartSweepTest opened by asserting that the manifest's android:name is what Robolectric instantiated. An application= override replaces the manifest rather than being checked against it, and applicationInfo.className reports the override too, so that claim is now unobservable from this source set and a rewritten version would assert the override against itself. The manifest link is device-only; the cast in setUp still catches the test app ceasing to extend the real one. AppStartSweepTest also joins the published Job instead of polling for ten seconds -- a poll cannot tell "swept" from "not started yet" -- and gains a test pinning that the sweep is complete when onCreate returns, which is the property the substitution exists for and the only place it is checked. Verified by mutation rather than by repetition. Putting the test app back on Dispatchers.IO reddens that test 5 times out of 5, while running the whole suite six times per arm caught nothing either way: at the rate #159 was observed at, a clean six-run arm is roughly a coin flip, so the comparison was underpowered and is not offered as evidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
29 lines
1.6 KiB
Kotlin
29 lines
1.6 KiB
Kotlin
package org.libremediaconverter
|
|
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.SupervisorJob
|
|
|
|
/**
|
|
* The [LibreMediaConverterApp] the JVM suite runs, differing from it in exactly one thing: the
|
|
* startup sweep runs inline on the thread that builds the Application instead of on
|
|
* `Dispatchers.Unconfined`.
|
|
*
|
|
* **This is #159.** Robolectric builds an `Application` per test class that asks for one, and each
|
|
* one launches a sweep over the shared `<cacheDir>/conversions/`. Nothing joins them, so a test
|
|
* asserting about a staged file is racing however many sweeps the classes before it left in
|
|
* flight — `OutputPublisherStagingTest` being the one that lost, at roughly one local run in six
|
|
* once wave 4 added ten more Robolectric classes. Making the sweep finish before `onCreate()`
|
|
* returns removes the race for every test at once rather than asking each to opt in; 27 of the
|
|
* suite's 58 Robolectric classes touch that directory, so opting in was not a real option.
|
|
*
|
|
* `Dispatchers.Unconfined` is what makes it inline: `sweepStaging()` is a plain function, so an
|
|
* `Unconfined` `launch` runs it to completion before returning. The `SupervisorJob` is kept so this
|
|
* differs from production in the dispatcher alone — a sweep that throws is logged and swallowed
|
|
* here exactly as it is there, rather than taking Application construction down with it and failing
|
|
* every test in the class for an unrelated reason.
|
|
*/
|
|
class TestLibreMediaConverterApp : LibreMediaConverterApp() {
|
|
override val sweepScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
|
}
|