ui tests: wait for a sent message to reach the chat list before opening the chat (plan 15.5 item 25 root cause)

This commit is contained in:
Narasimha-sc committed 2026-10-22 22:59:19 +00:00
1 parent 7a27abe9f2
commit f72bdb3c70
24 files changed
+202 -1

No files matched your search

@@ -2,6 +2,7 @@ package chat.simplex.app.ui.desktop
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.itemFileOf
import chat.simplex.app.ui.harness.sendsFileTo
@@ -18,6 +19,7 @@ class CopyShareDesktopTest : UiTest() {
connect(bob)
bob sends "@alice hello there"
bob sends "@alice second line"
awaitRowText(alice, "bob", "second line")
val chat = alice.chatList.open("bob")
chat.itemId("hello there")
chat.itemId("second line")
@@ -1,6 +1,7 @@
package chat.simplex.app.ui.desktop
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.connect
import chat.simplex.common.model.localTimestamp
import chat.simplex.common.platform.chatModel
@@ -12,6 +13,7 @@ class ItemInfoDesktopTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice question"
awaitRowText(alice, "bob", "question")
val chat = alice.chatList.open("bob")
chat.itemId("question")
chat.menu("question", "reply")
@@ -7,6 +7,7 @@ import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.withKeyDown
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.connect
import chat.simplex.common.ui.Ui
import org.junit.Test
@@ -16,6 +17,7 @@ class TextSelectionDesktopTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
for (m in MESSAGES) bob sends "@alice $m"
awaitRowText(alice, "bob", MESSAGES.last())
val chat = alice.chatList.open("bob")
val ids = MESSAGES.map { chat.itemId(it) }
check(!exists(Ui.Chat.TextSelectionCopy)) { "the selection Copy button is shown before a selection" }
@@ -33,6 +35,7 @@ class TextSelectionDesktopTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
for (m in MESSAGES) bob sends "@alice $m"
awaitRowText(alice, "bob", MESSAGES.last())
val chat = alice.chatList.open("bob")
val ids = MESSAGES.map { chat.itemId(it) }
setClipboardText(UNRELATED)
@@ -64,6 +64,7 @@ class ChatSearchTest : UiTest() {
bob sends "@alice alpha"
bob sends "@alice the needle"
bob sends "@alice omega"
awaitRowText(alice, "bob", "omega")
val chat = alice.chatList.open("bob")
val alpha = chat.itemId("alpha"); val needle = chat.itemId("the needle"); val omega = chat.itemId("omega")
val goTo = Ui.Chat.Item(needle).part("goTo")
@@ -2,6 +2,7 @@ package chat.simplex.app.ui.chat
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowItem
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.sendsFileTo
import chat.simplex.common.ui.Ui
@@ -19,6 +20,7 @@ class ContextMenuTest : UiTest() {
bob sends "@alice to be deleted"
bob sends "\\@alice to be deleted"
bob expects "message marked deleted"
awaitRowItem(alice, "bob", "bob's deleted message") { it.meta.itemDeleted != null }
val chat = alice.chatList.open("bob")
val text = chat.itemId("copy me")
val file = chat.itemId("bob's offer of offer.txt") { it.file?.fileName == "offer.txt" }
@@ -2,6 +2,7 @@ package chat.simplex.app.ui.chat
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.connect
import chat.simplex.common.model.ChatFeature
import chat.simplex.common.model.ChatInfo
@@ -20,6 +21,7 @@ class DeleteMessageTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice hello"
awaitRowText(alice, "bob", "hello")
val chat = alice.chatList.open("bob")
val id = chat.itemId("hello")
chat.menu("hello", "delete")
@@ -79,6 +81,7 @@ class DeleteMessageTest : UiTest() {
bob sends "@alice keep"
bob sends "@alice one"
bob sends "@alice two"
awaitRowText(alice, "bob", "two")
val chat = alice.chatList.open("bob")
val keep = chat.itemId("keep"); val one = chat.itemId("one"); val two = chat.itemId("two")
check(textsOf(Ui.Chat.Item(one)).let { t -> t.any { "one" in it } && "marked deleted" !in t }) { "`one` is not shown as sent before bob deletes it" }
@@ -2,6 +2,7 @@ package chat.simplex.app.ui.chat
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.described
import chat.simplex.common.platform.chatModel
@@ -54,6 +55,7 @@ class EditMessageTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice theirs"
awaitRowText(alice, "bob", "theirs")
val chat = alice.chatList.open("bob")
val received = chat.itemId("theirs")
chat.send("mine")
@@ -75,6 +77,7 @@ class EditMessageTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice hello"
awaitRowText(alice, "bob", "hello")
val chat = alice.chatList.open("bob")
val id = chat.itemId("hello")
check(textsOf(Ui.Chat.Item(id)).any { "hello" in it }) { "bob's bubble does not show `hello` before the edit" }
@@ -4,6 +4,7 @@ import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.CliPeer
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowItem
import chat.simplex.app.ui.harness.connect
import chat.simplex.common.model.CIFile
import chat.simplex.common.model.CIFileStatus
@@ -56,6 +57,7 @@ class FilesTest : UiTest() {
bob expectsMessage "/f @alice $FIXTURE_JPG"
bob expects "use /fc 2 to cancel sending"
bob.eventually("completed uploading file 2 (test.jpg) for alice", CONNECT_TIMEOUT_MS)
awaitRowItem(alice, "bob", "test.jpg as an image") { it.content.msgContent is MsgContent.MCImage && it.file?.fileName == "test.jpg" }
alice.chatList.open("bob")
val asFile = itemId("test.jpg sent as a file") { it.content.msgContent is MsgContent.MCFile && it.file?.fileName == "test.jpg" }
val asImage = itemId("test.jpg sent as an image") { it.content.msgContent is MsgContent.MCImage && it.file?.fileName == "test.jpg" }
@@ -29,6 +29,7 @@ class ForwardTest : UiTest() {
val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath")
connect(bob); connect(cath)
bob sends "@alice hey"
awaitRowText(alice, "bob", "hey")
alice.chatList.open("bob").forwardTo("hey", "cath")
cath.eventuallyMessage("alice> -> forwarded", CONNECT_TIMEOUT_MS)
cath expects " hey"
@@ -38,6 +39,7 @@ class ForwardTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice hey"
awaitRowText(alice, "bob", "hey")
val chat = alice.chatList.open("bob")
chat.forwardTo("hey", "Private notes")
val notes = alice.chatList.chatId("Private notes")
@@ -3,6 +3,7 @@ package chat.simplex.app.ui.chat
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.backToChatList
import chat.simplex.app.ui.harness.confirmAlert
import chat.simplex.app.ui.harness.connect
@@ -109,6 +110,7 @@ class ItemInfoTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice question"
awaitRowText(alice, "bob", "question")
val chat = alice.chatList.open("bob")
chat.itemId("question")
chat.send("plain")
@@ -138,6 +140,7 @@ class ItemInfoTest : UiTest() {
emptyGroup(alice, "team")
backToChatList()
bob sends "@alice hi"
awaitRowText(alice, "bob", "hi")
val chat = alice.chatList.open("bob")
chat.itemId("hi")
hideKeyboard()
@@ -3,6 +3,7 @@ package chat.simplex.app.ui.chat
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.PeerFlags
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.common.ui.Ui
import org.junit.Assert.assertFalse
import org.junit.Test
@@ -25,6 +26,7 @@ class MessagingTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice hello! how are you?"
awaitRowText(alice, "bob", "hello! how are you?")
val chat = alice.chatList.open("bob")
chat.menu("hello! how are you?", "reply")
chat.send("all good - you?")
@@ -47,6 +49,7 @@ class MessagingTest : UiTest() {
val alice = app("alice"); val bob = cli("bob", PeerFlags(reactions = true))
connect(bob)
bob sends "@alice hi"
awaitRowText(alice, "bob", "hi")
alice.chatList.open("bob").react("hi", "👍")
bob expectsMessage "alice> >> hi"
bob expects " + 👍"
@@ -3,6 +3,7 @@ package chat.simplex.app.ui.chat
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.KnownAppIssue
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.described
import chat.simplex.common.ui.Ui
@@ -15,6 +16,7 @@ class MultiSelectTest : UiTest() {
connect(bob)
bob sends "@alice one"
bob sends "@alice two"
awaitRowText(alice, "bob", "two")
val chat = alice.chatList.open("bob")
chat.send("mine")
bob expectsMessage "alice> mine"
@@ -80,6 +82,7 @@ class MultiSelectTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice one"
awaitRowText(alice, "bob", "one")
val chat = alice.chatList.open("bob")
chat.send("mine")
bob expectsMessage "alice> mine"
@@ -4,6 +4,7 @@ import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.KnownAppIssue
import chat.simplex.app.ui.harness.PeerFlags
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.closeContactInfo
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.offeredReactions
@@ -74,6 +75,7 @@ class ReactionTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice hi"
awaitRowText(alice, "bob", "hi")
val chat = alice.chatList.open("bob")
val hi = chat.itemId("hi")
assertTrue("the menu of `hi` does not offer 👍 while reactions are allowed", "👍" in offeredReactions(hi))
@@ -4,6 +4,7 @@ import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.TestServers
import chat.simplex.app.ui.harness.PREVIEW_IMAGE_DATA
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowItem
import chat.simplex.app.ui.harness.attachFile
import chat.simplex.app.ui.harness.closeItemMenu
import chat.simplex.app.ui.harness.connect
@@ -60,6 +61,7 @@ class ReceiveFileTest : UiTest() {
bob expectsMessage "/f @alice $FIXTURE_JPG"
bob expects "use /fc 1 to cancel sending"
bob.eventually("completed uploading file 1 (test.jpg) for alice", CONNECT_TIMEOUT_MS)
awaitRowItem(alice, "bob", "bob's image") { it.content.msgContent is MsgContent.MCImage }
val chat = alice.chatList.open("bob")
val id = chat.itemId("bob's image") { it.content.msgContent is MsgContent.MCImage }
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the image received and shown in full") {
@@ -80,6 +82,7 @@ class ReceiveFileTest : UiTest() {
TestServers.stop("xftp1")
bob sends "/f @alice $path"
bob expects "use /fc 1 to cancel sending"
awaitRowItem(alice, "bob", "bob's $WAITING_NAME") { it.file?.fileName == WAITING_NAME }
val chat = alice.chatList.open("bob")
val id = chat.itemId("bob's $WAITING_NAME") { it.file?.fileName == WAITING_NAME }
check(itemFileOf(id)?.fileStatus is CIFileStatus.RcvInvitation) { "$WAITING_NAME before the click: ${itemFileOf(id)?.fileStatus}" }
@@ -111,6 +114,7 @@ class ReceiveFileTest : UiTest() {
bob.eventually("completed uploading file 1 ($LARGE_PNG_NAME) for alice", CONNECT_TIMEOUT_MS)
bob sends "/img @alice $FIXTURE_JPG"
bob.eventually("completed uploading file 2 (test.jpg) for alice", CONNECT_TIMEOUT_MS)
awaitRowItem(alice, "bob", "bob's test.jpg") { it.file?.fileName == "test.jpg" }
val chat = alice.chatList.open("bob")
val large = chat.itemId("bob's large image") { it.content.msgContent is MsgContent.MCImage && it.file?.fileName == LARGE_PNG_NAME }
val small = chat.itemId("bob's small image") { it.content.msgContent is MsgContent.MCImage && it.file?.fileName == "test.jpg" }
@@ -140,6 +144,7 @@ class ReceiveFileTest : UiTest() {
val aliceId = bob.contactId("alice")
bob sends """/_send @$aliceId json [{"filePath": "$path", "msgContent": {"type": "text", "text": "$CAPTION"}}]"""
bob.eventually("completed uploading file 1 ($DELETED_NAME) for alice", CONNECT_TIMEOUT_MS)
awaitRowItem(alice, "bob", "bob's $DELETED_NAME") { it.file?.fileName == DELETED_NAME }
val chat = alice.chatList.open("bob")
val id = chat.itemId("`$CAPTION` with $DELETED_NAME") { it.content.text == CAPTION && it.file?.fileName == DELETED_NAME }
rightClick(Ui.Chat.Item(id))
@@ -7,6 +7,7 @@ import chat.simplex.app.ui.harness.CliPeer
import chat.simplex.app.ui.harness.PeerFlags
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.backToChatList
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.connectPeers
@@ -97,6 +98,7 @@ class ReceivedMessagesTest : UiTest() {
val alice = app("alice"); val bob = cli("bob", PeerFlags(markRead = true))
connect(bob)
bob sends "@alice before"
awaitRowText(alice, "bob", "before")
val chat = alice.chatList.open("bob")
val before = chat.itemId("before")
bob sends "/set disappear @alice yes $TIMER_S"
@@ -2,6 +2,7 @@ package chat.simplex.app.ui.chat
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.described
import chat.simplex.app.ui.harness.ownGroup
@@ -43,6 +44,7 @@ class ReplyTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice hello"
awaitRowText(alice, "bob", "hello")
val chat = alice.chatList.open("bob")
chat.itemId("hello")
waitFor(Ui.Chat.Message)
@@ -61,6 +63,7 @@ class ReplyTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice question"
awaitRowText(alice, "bob", "question")
val chat = alice.chatList.open("bob")
chat.itemId("question")
chat.send("draft answer")
@@ -2,6 +2,7 @@ package chat.simplex.app.ui.chat
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.attachFile
import chat.simplex.app.ui.harness.attachImage
import chat.simplex.app.ui.harness.connect
@@ -72,6 +73,7 @@ class SendAttachmentTest : UiTest() {
connect(bob)
val photo = jpegFile(PHOTO_NAME)
bob sends "@alice $QUESTION"
awaitRowText(alice, "bob", QUESTION)
val chat = alice.chatList.open("bob")
chat.menu(QUESTION, "reply")
poll(what = "the reply bar and no image preview") { seesDescription("Context icon") && !seesDescription("Cancel image preview") }
@@ -1,5 +1,6 @@
package chat.simplex.app.ui.harness
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.GroupProfile
import chat.simplex.common.platform.chatController
import kotlinx.coroutines.runBlocking
@@ -23,3 +24,6 @@ fun newGroupsViaApi(vararg names: String) = names.forEach {
fun UiScope.awaitRowText(app: App, name: String, text: String) =
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`$text` on $name's row") { text in app.chatList.rowTexts(name) }
fun UiScope.awaitRowItem(app: App, name: String, what: String, match: (ChatItem) -> Boolean) =
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "$what as $name's last message in the chat list") { app.chatList.chat(name).chatItems.lastOrNull()?.let(match) == true }
@@ -6,6 +6,7 @@ import chat.simplex.app.ui.harness.KnownAppIssue
import chat.simplex.app.ui.harness.TestServers
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.backToChatList
import chat.simplex.app.ui.harness.clearChatFromInfo
import chat.simplex.app.ui.harness.closeContactInfo
@@ -396,6 +397,7 @@ class ContactInfoTest : UiTest() {
connect(bob)
val direct = "@${bob.contactId("alice")}"
bob sends "@alice r0"
awaitRowText(alice, "bob", "r0")
val chat = alice.chatList.open("bob")
chat.itemId("r0")
awaitReceipt(bob, direct, "r0")
@@ -2,6 +2,7 @@ package chat.simplex.app.ui.main
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.closeContactInfo
import chat.simplex.app.ui.harness.connect
import chat.simplex.app.ui.harness.contactOf
@@ -21,6 +22,7 @@ class ContactPreferencesTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice one"
awaitRowText(alice, "bob", "one")
val chat = alice.chatList.open("bob")
val one = chat.itemId("one")
bob sends "\\@alice one"
@@ -3,6 +3,7 @@ package chat.simplex.app.ui.main
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.archiveContact
import chat.simplex.app.ui.harness.backToChatList
import chat.simplex.app.ui.harness.connect
@@ -125,6 +126,7 @@ class DeleteContactTest : UiTest() {
val alice = app("alice"); val bob = cli("bob")
connect(bob)
bob sends "@alice hello"
awaitRowText(alice, "bob", "hello")
val chat = alice.chatList.open("bob")
chat.itemId("hello")
check(contactOf("bob").active && !seesText("contact deleted")) { "bob's contact is inactive before he deletes it" }
@@ -7,6 +7,7 @@ import chat.simplex.app.ui.harness.KnownAppIssue
import chat.simplex.app.ui.harness.PassphraseScreen
import chat.simplex.app.ui.harness.UiScope
import chat.simplex.app.ui.harness.UiTest
import chat.simplex.app.ui.harness.awaitRowText
import chat.simplex.app.ui.harness.backToChatList
import chat.simplex.app.ui.harness.chatData
import chat.simplex.app.ui.harness.chatRunningInCore
@@ -225,6 +226,7 @@ class ChatDataTest : UiTest() {
bob expects "use /fc 1 to cancel sending"
bob.eventually("completed uploading file 1 (test.jpg) for alice", CONNECT_TIMEOUT_MS)
bob sends "@alice $KEPT"
awaitRowText(alice, "bob", KEPT)
val chat = alice.chatList.open("bob")
chat.itemId(KEPT)
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's image to be received") { imageStatus() is CIFileStatus.RcvComplete }
@@ -1677,7 +1677,7 @@ Fixed in the same review: `createGroup_opensChatAndInviteSheet` now checks the o
24. **Desktop: the system theme detector thread crashes without a session D-Bus** (jSystemThemeDetector `GnomeThemeDetector$DetectorThread.run`, `GnomeThemeDetector.java:119`: `readLine` returns null when `gsettings monitor` exits, then `NullPointerException` on `split`). Seen on every GitHub runner job (2026-10-22): an Ubuntu runner has no session bus, so `gsettings monitor` exits at once. A user on a Linux desktop without a session bus or without the GNOME schemas gets the same uncaught exception in a background thread; theme following stops. The `ui-tests` workflow runs the desktop tests with a session bus on a filesystem socket, so the tests do not hit it. Related: memory of an NPE in the library's KDE path on an unset `XDG_CURRENT_DESKTOP`.
25. **After opening a chat with a new message, the message is missing or out of view** (desktop; formerly open flake 15.4.1b). The chat list preview shows the new message with an unread count; the open chat shows its beginning, with the unread button showing 1, and the message is either not in the open chat's items at all (seen when it arrived while the chat was being opened) or loaded but laid out outside the visible area (seen in `timed_chatPref_receivedMessagesDisappear` on 2026-10-22, after `open()` had waited for events to settle). Rare locally (1 of 4 runs), 5 tests in one GitHub runner run, where the runner is slower. `ChatListScreen.open()` now waits for app events to settle (`quiesce()`) before it clicks the row, and `ChatScreen.itemId()` taps the unread button once, as a user would, when the item is loaded but not shown; `ReceivedMessagesTest.openChat_whileMessagesArrive_showsAll` clicks the row while bob's messages are arriving and is marked `@KnownAppIssue("15.5 item 25", intermittent = true)`.
25. **A message saved while its chat is being opened is dropped from the open chat** (desktop; formerly open flake 15.4.1b; root cause in `plans/ui-tests/drafts/finding-25-missing-unread.md`). `apiGetChat` reads the items and the unread count in one transaction (`Store/Messages.hs:1356-1364`), and the app publishes that snapshot in two main-thread steps (`ChatItemsLoader.kt:64-91`): first the chat list's unread count (`:75`), then `chatItems` and only then `chatModel.chatId` (`:80-81`). A message saved after the snapshot and handled before `chatId` is set raises the row's unread count but is not added to the open chat (`ChatModel.kt:641-657` compares with the old `chatId`), and nothing loads it later (the snapshot has nothing after its last item). Seen as messages 1, 2, 3 and 5 of five, with the unread button showing 1; or a single new message missing. The same window affects opening around an item and reloading an open chat. Tests that send and then open now wait for the message to reach the chat list first (`awaitRowText` / `awaitRowItem`); `ReceivedMessagesTest.openChat_whileMessagesArrive_showsAll` opens while messages arrive and is marked `@KnownAppIssue("15.5 item 25", intermittent = true)`. Fix idea, not done: publish the stats, the items and `chatId` in one main-thread block, and when the chat received events during the load, load once more after the last loaded item.
### 15.6 Catalogue corrections applied from passing tests (exhaustive pass)
@@ -0,0 +1,146 @@
# Finding 25: a received message is missing from a chat that opens while it arrives, and "unread above" shows 1
Status: root cause found by reading the code. Nothing was run and no code was changed.
Code base: /home/user/wt-uitest at 7a27abe9f (base master cfcce1b43). The branch changes to the files involved only add UI keys
(`git diff cfcce1b43` on ChatModel.kt, ChatView.kt and ChatListNavLinkView.kt). ChatItemsLoader.kt, ChatItemsMerger.kt and
Store/Messages.hs are the same as on master.
## Conclusion
The item is never in `chatsContext.chatItems`. It is not a display or merge problem. Opening a chat is a non-atomic "read a snapshot,
then publish it" sequence:
1. The core reads the chat (items + stats) in one DB transaction (`APIGetChat` -> `withFastStore` -> `getDirectChatInitial_`).
2. Later, the app publishes that snapshot on Main: it overwrites the chat-list stats, then `replaceAll`s `chatItems`, and only then
sets `chatModel.chatId`.
A `NewChatItems` event for that chat that is committed after step 1 and handled on Main before `chatId` is set:
- is **not added** to `chatItems`, because `chatItemBelongsToScope` compares it with the old `chatId` (null or another chat);
- **is counted** in the chat-list `unreadCount` (+1);
- is never fetched again: the snapshot says nothing follows its last item (`navInfo.afterTotal == 0`, so there are no splits and no
`After` pagination), and later events only append newer items.
The item stays out of the chat until it is reopened. The item after it (5) arrives once `chatId` is set and is appended normally.
That is why the chat shows 1, 2, 3, 5.
"Unread above = 1" and the chat-list badge of 1 both come from the same counter. It counts item 4 (still `RcvNew` in the DB), but
item 4 is not in the list, so nothing can ever mark it read.
## Code path (file:line)
Opening the chat:
- `ChatListNavLinkView.kt:68`: the row click runs `scope.launch { directChatAction(..) }`, which calls `openDirectChat` -> `openChat`
(`:220-243`) -> `apiLoadMessages(.., ChatPagination.Initial(INITIAL_COUNT))`.
- `ChatItemsLoader.kt:35`: `apiGetChat(..)` -> `SimpleXAPI.kt:1205` `sendCmd` -> `:974` `withContext(Dispatchers.IO)` (FFI call).
- Core: `Commands.hs:702` `withFastStore (getDirectChat ..)`. `Store/Messages.hs:1356-1364` `getDirectChatInitial_` reads
`unreadCount` and the items around the pivot in **one transaction**. `getContactNavInfo_` (`:1413`) returns `afterTotal`/`afterUnread`
relative to the snapshot's last item, so it is 0/0 when the snapshot ends at the newest committed item.
- `ChatItemsLoader.kt:64-91` (`Initial`), in two separate `withContext(Dispatchers.Main)` blocks:
- Block 1, `:68-77`, line `:75`: `updateChatStats(.., chat.chatStats ..)` **overwrites** the chat-list `unreadCount` with the
snapshot value.
- Block 2, `:79-90`: `:80` `chatsCtx.chatItems.replaceAll(chat.chatItems)` (snapshot only), `:81` `chatModel.chatId.value = chat.id`,
`:82` `splits = newSplits` (empty because `afterTotal == 0`, `:65`), `:84` `unreadAfterItemId` = the snapshot's last item, and
`:87-89` `unreadTotal`/`unreadAfter`/`unreadAfterNewestLoaded` from the snapshot.
The event that arrives while the chat opens:
- `SimpleXAPI.kt:3047` `is CR.NewChatItems -> withBGApi { .. withContext(Main) { chatsContext.addChatItem(..) } }` (`:3053`).
- `ChatModel.kt:620`: the chat-list `unreadCount + 1`. This happens whether or not the chat is open.
- `ChatModel.kt:641-651`: the item is added to `chatItems` only `if (chatItemBelongsToScope(cInfo, cItem))`. `:657` defines that as
`chatId.value == cInfo.id ..`. While the chat is opening, `chatId` is still the previous value, so the item is dropped. The
"already in the list" dedup at `:643` shows the code expects only the opposite race, where the item is in the snapshot and the
event comes later.
Nothing brings the item back afterwards:
- `ChatView.kt:2512-2526` `loadLastItems` / `lastItemsLoaded`, and `PreloadItems` (`:2759+`), only fetch beyond a split. `splits` is
empty (`ChatItemsLoader.kt:65`).
- `ChatItemsMerger.kt` (`MergedItems.create`) only presents `chatItems`. Received text items have `mergeCategory == null`, so they are
always `Single` and cannot be hidden by grouping. The merger is not involved.
Why the unread-above button says 1:
- `ChatView.kt:263-266`: `unreadCount` = the chat-list `chatStats.unreadCount` (which includes item 4).
- `ChatView.kt:2641-2646`: `bottomUnreadCount = unreadCount - oldestVisible.unreadBefore`. `:2711-2712`:
`topUnreadCount = unreadCount - bottomUnreadCount`, which equals `oldestVisible.unreadBefore`.
- `ChatItemsMerger.kt:30,42,44`: `unreadBefore` starts at `unreadCount - unreadAfterNewestLoaded` (or `unreadCount - unreadAfter` at
the `unreadAfterItemId`) and goes down by 1 for each `RcvNew` item **present in the list**. Item 4 is counted but not present, so
after 1, 2, 3 and 5 are marked read, every item has `unreadBefore = 1`, and the button shows 1.
- Marking items read (`ChatView.kt:767-785` -> `ChatModel.kt:900-918`, `:909`) subtracts only the items that were found and marked
in `chatItems`. The badge never goes below the 1 that item 4 represents, and item 4 is never shown, so it is never marked.
### Unread arithmetic for the observed run (1, 2, 3, 5 shown, badge 1, unread-above 1)
- Items 1-3 are committed before the snapshot. Block 1 sets the chat-list count to 3, whatever was counted before.
- Item 4's event runs **between block 1 and block 2**: count 4, item dropped.
- Item 5's event runs after block 2: count 5, item appended (`addToChatItems` -> `chatState.itemAdded`).
- 1, 2, 3 and 5 become visible and are marked read: 5 - 4 = **1**. The badge and the unread-above button both show 1. The DB really
has 1 unread item (4).
- If item 4's event lands **before block 1**, block 1's overwrite (`:75`) erases its +1. The item is still missing, but the badge and
the button show 0 while the DB still has 1 unread item. That is the same bug with the counter error going the other way.
So the unread counting is not a separate bug. The count is correct for the DB and wrong for what the list holds.
## Timing: why it happens and why slow runners hit it more
1. **The test opens the chat while messages are still being delivered.** `bob sends` (`PeerLines.kt:24-27`) waits only for the CLI
echo of the command, not for delivery to alice. `ChatListScreen.open` (`Screens.kt:83-91`) calls `quiesce()`
(`Helpers.kt:20-24`). That waits for "no API call in flight and no app event for 1 s". Before delivery starts there is no event at
all, so if alice's last connection event is more than 1 s old, `quiesce` returns at once and the click lands while bob's messages
are in transit. Delivery is one message at a time (the SMP server delivers the next message after the ACK), so a 5-message burst
spans the open. The affected tests all have `X sends ..` followed directly by `open`:
`TextSelectionDesktopTest.kt:18-19,35-36`, `ReceivedMessagesTest.kt:99-100` ("before"), `MessagingTest.kt:27-28`
("hello! how are you?").
2. **The race window.** It runs from the core's snapshot read to block 2 at `ChatItemsLoader.kt:79`. In the UI test it is much wider
than in the app. The test composition runs on `StandardTestDispatcher()` (`UiTest.kt:39`) and moves forward only when the test
thread calls `waitForIdle`/`advanceTimeBy` (one `poll` iteration, at least 16 ms plus a sleep). `Dispatchers.Main` is the real Swing
EDT (`kotlinx-coroutines-swing`, common/build.gradle.kts:135), where events are handled freely. The continuation after `apiGetChat`
and the return from block 1 both wait for the next pump, so the EDT can run any number of `addChatItem` calls before block 1 and
between block 1 and block 2. On slow runners, delivery and pumping are both slower, and the window lines up with the delivery burst
more often.
3. **The real app is affected too** (inference: not reproduced in the app, but the code path is the same). The window from the core
read to block 2 always exists: FFI return, JSON decode on IO, then dispatch back to the click scope and to Main. It is shorter, and
blocks 1 and 2 may run back to back. So the app mostly shows the "badge 0, item missing" variant. The same race also affects:
- `Around` with `openAroundItemId` (`ChatItemsLoader.kt:135-164`; `chatId` is set at `:159`);
- `Initial` reloads of a chat that is already open. `ChatView.kt:2617` (`scrollToTopUnread`) and `apiFindMessages`
(`ChatListNavLinkView.kt:255-260`) are examples. There an event is added to the old list and then erased by `replaceAll` at `:80`.
- Status, edit and reaction events (`upsertChatItem`/`updateChatItem`, `ChatModel.kt:670+`) that arrive during the window are lost
the same way for items in the snapshot.
## Minimal reproduction
Deterministic in the desktop UI harness (no app change):
1. `connect(bob)`. Wait until alice is quiet (`quiesce()`).
2. `click(Ui.ChatList.Row(bobChatId))` once. Its `waitForIdle` runs the click coroutine up to `withContext(IO)` in `sendCmd`, and the
core reads the snapshot with no message.
3. **Without pumping the test dispatcher** (use only `Thread.sleep`, no `poll`/`waitForIdle`): `bob types "@alice before"`, then
sleep-loop until `chatModel.chats.value.first { it.id == bobChatId }.chatItems.last().content.text == "before"`. That model is
updated on the EDT. `chatModel.chatId.value` is still null at that point.
4. Resume with `poll`. Expected result: `chatModel.chatsContext.chatItems.value.none { it.content.text == "before" }`, and the item is
missing from the opened chat for good. This variant has badge 0, because the event landed before block 1.
For the badge-1 / unread-above-1 variant, let the test thread pump exactly until `chatModel.chats` shows the snapshot's stats
(block 1 done) but `chatId` is still null, and deliver the message then.
In the app (manual, needs a temporary debug delay): put `delay(3000)` after `apiGetChat` in `apiLoadMessages`
(`ChatItemsLoader.kt:35`), open a contact's chat, and send a message from the contact within those 3 s. The message shows in the
chat-list preview but not in the opened chat until it is reopened.
## Fix ideas (not implemented)
App fix (the real bug). Make the published snapshot include everything committed after it was read:
- **A (recommended). Detect events that arrive during the load, then reload or fill the gap.**
- Keep a per-`ChatsContext` "events for chat X" sequence counter, bumped in `addChatItem`/`upsertChatItem`/`updateChatItem` for any
chat id, whether or not it is in scope.
- Read it before `apiGetChat` and again in a single combined Main block (blocks 1 and 2 merged, so stats, items and `chatId` change
atomically).
- If it changed, run one follow-up `ChatPagination.After(lastLoadedId)` load (the merge path at `ChatItemsLoader.kt:111-134` already
dedups and inserts after the anchor). Or rerun `Initial` once.
- This also covers lost status and edit updates if the follow-up is an `Initial` reload.
- **B. Buffer events for the chat being opened.** Set a "pending open chat id" before `apiGetChat`. While it is set, `addChatItem`
buffers items for that chat. In the combined Main block, append buffered items not in the snapshot, call `chatState.itemAdded` for
each, and do not overwrite the chat-list stats with the older snapshot count. This needs no extra API call, but it handles only new
items, not updates.
- With either option, merge the two Main blocks in the `Initial` branch. Today block 1's overwrite at `:75` can also erase increments
from events that the snapshot does not contain, which gives the badge-0 variant.
Test-side (separate from the app fix, and it would hide the bug if done alone): before `chatList.open(..)` after `X sends ..`, wait
for delivery, for example by polling until the chat-list preview text is the last sent message and `unreadCount == N`. `quiesce()`
does not guarantee delivery. Keep one dedicated regression test for the race, using the deterministic recipe above, once the app fix
lands.