@@ -164,7 +164,7 @@ public fun TestScope.runTest(
164
164
): TestResult = asSpecificImplementation().let {
165
165
it.enter()
166
166
createTestResult {
167
- runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.leave() }
167
+ runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl ::tryGetCompletionCause, testBody) { it.leave() }
168
168
}
169
169
}
170
170
@@ -190,18 +190,20 @@ internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
190
190
* Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
191
191
* [dispatchTimeoutMs] milliseconds, and performing the [cleanup] procedure at the end.
192
192
*
193
+ * [tryGetCompletionCause] is the [JobSupport.completionCause], which is passed explicitly because it is protected.
194
+ *
193
195
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
194
196
* return a list of uncaught exceptions that should be reported at the end of the test.
195
197
*/
196
198
internal suspend fun <T : AbstractCoroutine <Unit >> runTestCoroutine (
197
199
coroutine : T ,
198
200
dispatchTimeoutMs : Long ,
201
+ tryGetCompletionCause : T .() -> Throwable ? ,
199
202
testBody : suspend T .() -> Unit ,
200
203
cleanup : () -> List <Throwable >,
201
204
) {
202
205
val scheduler = coroutine.coroutineContext[TestCoroutineScheduler ]!!
203
- /* * TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
204
- * [TestCoroutineDispatcher], because the event loop is not started. */
206
+ /* * TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on JS. */
205
207
coroutine.start(CoroutineStart .UNDISPATCHED , coroutine) {
206
208
testBody()
207
209
}
@@ -222,13 +224,7 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
222
224
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
223
225
}
224
226
onTimeout(dispatchTimeoutMs) {
225
- try {
226
- cleanup()
227
- } catch (e: UncompletedCoroutinesError ) {
228
- // we expect these and will instead throw a more informative exception just below.
229
- emptyList()
230
- }.throwAll()
231
- throw UncompletedCoroutinesError (" The test coroutine was not completed after waiting for $dispatchTimeoutMs ms" )
227
+ handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
232
228
}
233
229
}
234
230
}
@@ -244,6 +240,41 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
244
240
cleanup().throwAll()
245
241
}
246
242
243
+ /* *
244
+ * Invoked on timeout in [runTest]. Almost always just builds a nice [UncompletedCoroutinesError] and throws it.
245
+ * However, sometimes it detects that the coroutine completed, in which case it returns normally.
246
+ */
247
+ private inline fun <T : AbstractCoroutine <Unit >> handleTimeout (
248
+ coroutine : T ,
249
+ dispatchTimeoutMs : Long ,
250
+ tryGetCompletionCause : T .() -> Throwable ? ,
251
+ cleanup : () -> List <Throwable >,
252
+ ) {
253
+ val uncaughtExceptions = try {
254
+ cleanup()
255
+ } catch (e: UncompletedCoroutinesError ) {
256
+ // we expect these and will instead throw a more informative exception.
257
+ emptyList()
258
+ }
259
+ val activeChildren = coroutine.children.filter { it.isActive }.toList()
260
+ val completionCause = if (coroutine.isCancelled) coroutine.tryGetCompletionCause() else null
261
+ var message = " After waiting for $dispatchTimeoutMs ms"
262
+ if (completionCause == null )
263
+ message + = " , the test coroutine is not completing"
264
+ if (activeChildren.isNotEmpty())
265
+ message + = " , there were active child jobs: $activeChildren "
266
+ if (completionCause != null && activeChildren.isEmpty()) {
267
+ if (coroutine.isCompleted)
268
+ return
269
+ // TODO: can this really ever happen?
270
+ message + = " , the test coroutine was not completed"
271
+ }
272
+ val error = UncompletedCoroutinesError (message)
273
+ completionCause?.let { cause -> error.addSuppressed(cause) }
274
+ uncaughtExceptions.forEach { error.addSuppressed(it) }
275
+ throw error
276
+ }
277
+
247
278
internal fun List<Throwable>.throwAll () {
248
279
firstOrNull()?.apply {
249
280
drop(1 ).forEach { addSuppressed(it) }
0 commit comments