From 261cb3bb6b8110ca5e5118820b3a652d8ef2acca Mon Sep 17 00:00:00 2001 From: admin <33443359+miuiadmin@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:13:08 +0800 Subject: [PATCH] test(android): pin composer attachment encode hop to the test scheduler (#2189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ComposerAttachments harness left the constructor default encodeDispatcher (Dispatchers.Default, a real thread pool) in place. With two uploads in flight the second-launched one can finish encoding first, reach FakeUploadApi.uploadFile first, and consume the scripted results deque out of launch order — the queued failure gets stolen by the upload meant to succeed, so the robbed chip never settles to its scripted state and items.first {} hangs until runTest's dispatch timeout (UncompletedCoroutinesError, intermittent under suite load). Pin the encode hop to the scope's own dispatcher (the runTest scheduler in every test), making upload arrival order equal launch order deterministically; upload still hops dispatchers as in production. Add a regression-lock test scripting two results over two concurrent adds. Product code is untouched: upload() keys results by attachment id and is arrival-order independent — the ordering dependency lives purely in the test fake's deque. via [HAPI](https://hapi.run) Co-authored-by: Claude Code Co-authored-by: HAPI --- .../attachments/ComposerAttachmentsTest.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/attachments/ComposerAttachmentsTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/attachments/ComposerAttachmentsTest.kt index 8d83e02d..518387dc 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/chat/attachments/ComposerAttachmentsTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/chat/attachments/ComposerAttachmentsTest.kt @@ -4,11 +4,14 @@ import app.hapi.data.api.AttachmentUploadApi import app.hapi.protocol.wire.DeleteUploadResponse import app.hapi.protocol.wire.UploadFileResponse import java.util.Base64 +import kotlin.coroutines.ContinuationInterceptor import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -57,12 +60,29 @@ private fun prepared( class ComposerAttachmentsTest { + /** + * Test scaffold: production shape except the base64 encode hop, which is + * pinned to the scope's own dispatcher instead of the constructor default + * `Dispatchers.Default`. That default is a real thread pool: with two + * uploads in flight the second-launched one can finish encoding first and + * reach `uploadFile` first, consuming the fake's scripted `results` deque + * out of launch order (the queued failure stolen by the upload meant to + * succeed) — the robbed chip then never settles to its scripted state and + * `items.first {}` hangs until runTest's dispatch timeout + * (`UncompletedCoroutinesError`, observed intermittently under suite + * load). Keeping the hop on the runTest scheduler makes upload arrival + * order equal launch order deterministically, while `upload` still hops + * dispatchers exactly as in production. The `Dispatchers.Default` + * fallback only covers scopes whose interceptor is not a dispatcher. + */ private fun harness(scope: kotlinx.coroutines.CoroutineScope, api: FakeUploadApi) = ComposerAttachments( api = api, sessionId = SESSION, scope = scope, detachedCleanupScope = scope, + encodeDispatcher = scope.coroutineContext[ContinuationInterceptor] as? CoroutineDispatcher + ?: Dispatchers.Default, ) @Test @@ -119,6 +139,36 @@ class ComposerAttachmentsTest { assertEquals(api.uploads.value[0].contentBase64, api.uploads.value[1].contentBase64) } + @Test + fun `concurrent uploads take scripted results in launch order`() = runTest { + // Regression lock for the scheduler pin in `harness`: the fake's + // scripted `results` deque is consumed by uploadFile arrival order, + // which must equal launch order. With the production + // Dispatchers.Default encode hop the second upload can reach the + // fake first, steal the queued success, and flip which chip settles + // Ready (surfaced as an UncompletedCoroutinesError flake when the + // first chip then never matches the awaited state). + val api = FakeUploadApi().apply { + results += Result.success(UploadFileResponse(success = true, path = "/uploads/ok.jpg")) + results += Result.failure(RuntimeException("boom")) + } + val tray = harness(backgroundScope, api) + tray.add(prepared(id = "first", filename = "ok.jpg")) + tray.add(prepared(id = "second", filename = "bad.jpg")) + + val settled = tray.items.first { list -> + list.size == 2 && list.all { it.status != ComposerAttachmentStatus.Uploading } + } + + assertEquals( + listOf( + ComposerAttachmentStatus.Ready to "first", + ComposerAttachmentStatus.Failed to "second", + ), + settled.map { it.status to it.id }, + ) + } + @Test fun `success=false responses settle Failed too`() = runTest { val api = FakeUploadApi()