From 377cdb17c18c291eb5a22db9fe7aeb44b49610ad Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:50:43 +0000 Subject: [PATCH] ci: ui-tests builds the APKs once, desktop jobs get a session bus; open() waits for events to settle; findings 24-25 --- .github/workflows/ui-tests.yml | 100 +++++++++++++----- .../chat/simplex/app/ui/harness/UiTest.kt | 2 +- .../chat/simplex/app/ui/harness/UiTest.kt | 2 +- .../common/src/uiTestShared/README.md | 1 + .../app/ui/chat/ReceivedMessagesTest.kt | 15 +++ .../chat/simplex/app/ui/harness/Screens.kt | 1 + .../2026-10-10-desktop-ui-test-dsl-plan.md | 4 + scripts/ci/ui-tests/README.md | 9 +- 8 files changed, 104 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 46e315ed7e..3c981f3f99 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -232,9 +232,18 @@ jobs: - name: Install libraries run: | sudo apt-get update - sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev fonts-dejavu-core + sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev fonts-dejavu-core dbus libglib2.0-bin gsettings-desktop-schemas dpkg-query -W libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev fonts-dejavu-core + # the desktop app's theme detector runs `gsettings monitor`, which needs a session bus (plan §15.5 item 24); a + # filesystem socket reaches it from the tests' network namespace, where an abstract socket would not + - name: Start a session bus + run: | + mkdir -p "$RUNNER_TEMP/dbus" + addr=$(dbus-daemon --session --address="unix:dir=$RUNNER_TEMP/dbus" --fork --print-address=1 | head -n 1) + echo "DBUS_SESSION_BUS_ADDRESS=$addr" >> "$GITHUB_ENV" + DBUS_SESSION_BUS_ADDRESS="$addr" gsettings get org.gnome.desktop.interface gtk-theme + # uitest.sh runs the tests in their own user, PID and network namespaces, which Ubuntu 24.04 restricts - name: Allow user namespaces run: | @@ -308,9 +317,62 @@ jobs: # an emulator runs one test at a time, so the tests are split over several. Each shard builds the APKs itself: it # compiles the host-side bridge anyway, whose classpath names files in the job's own Gradle cache + android-apk: + name: Android app and test APKs + needs: [plan, android-core] + # the core is skipped when an earlier run built it + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' && needs.plan.outputs.run-android == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Download core library + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.android-core }} + path: ~/core + run-id: ${{ needs.plan.outputs.android-core-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Install core library + run: scripts/ci/android/install-core.sh ~/core/${{ env.ANDROID_CORE_PKG }} + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: corretto + java-version: ${{ env.JAVA_VER }} + + # as android-emu builds its APK, plus the test APK + - name: Build app and tests + working-directory: apps/multiplatform + run: | + ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pandroid.injected.build.abi=arm64-v8a \ + :android:assembleFossDebug :android:assembleFossDebugAndroidTest + mkdir -p "$RUNNER_TEMP/apk" + cp "android/build/intermediates/apk/foss/debug/$APK" "$RUNNER_TEMP/apk" + test_apk=$(find android/build -name '*androidTest*.apk' | head -n 1) + [ -n "$test_apk" ] || { echo "error: no androidTest APK"; find android/build -name '*.apk'; exit 1; } + cp "$test_apk" "$RUNNER_TEMP/apk/androidTest.apk" + ls -la "$RUNNER_TEMP/apk" + unzip -l "$RUNNER_TEMP/apk/$APK" | grep -E ' lib/' + + - name: Upload APKs + uses: actions/upload-artifact@v7 + with: + name: ui-tests-apk + path: ${{ runner.temp }}/apk + compression-level: 0 + retention-days: 3 + if-no-files-found: error + android: name: Android UI tests (${{ matrix.shard }}) - needs: [plan, android-core, bin] + needs: [plan, android-apk, bin] # the builds are skipped when an earlier run built them if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' && needs.plan.outputs.run-android == 'true' }} runs-on: ubuntu-24.04 @@ -341,17 +403,6 @@ jobs: sudo apt-get install -y libpulse0 libegl1 libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev dpkg-query -W libpulse0 libegl1 libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev - - name: Download core library - uses: actions/download-artifact@v8 - with: - name: ${{ needs.plan.outputs.android-core }} - path: ~/core - run-id: ${{ needs.plan.outputs.android-core-run || github.run_id }} - github-token: ${{ github.token }} - - - name: Install core library - run: scripts/ci/android/install-core.sh ~/core/${{ env.ANDROID_CORE_PKG }} - - name: Download CLI and test servers uses: actions/download-artifact@v8 with: @@ -369,21 +420,20 @@ jobs: distribution: corretto java-version: ${{ env.JAVA_VER }} - # as android-emu builds its APK, plus the test APK and the host-side bridge's classpath, which uitest-android.sh - # then finds up to date; all before install-sdk.sh points ANDROID_HOME at an SDK with only the emulator - - name: Build app, tests and bridge + - name: Download APKs + uses: actions/download-artifact@v8 + with: + name: ui-tests-apk + path: ${{ runner.temp }}/apk + + # the host-side bridge's classpath, which uitest-android.sh then finds up to date; before install-sdk.sh points + # ANDROID_HOME at an SDK with only the emulator + - name: Build bridge working-directory: apps/multiplatform run: | - ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pandroid.injected.build.abi=arm64-v8a \ - :android:assembleFossDebug :android:assembleFossDebugAndroidTest :common:uitestBridgeClasspath - pkill -f KotlinCompileDaemon || true - mkdir -p "$RUNNER_TEMP/apk" - cp "android/build/intermediates/apk/foss/debug/$APK" "$RUNNER_TEMP/apk" - test_apk=$(find android/build -name 'android-foss-debug-androidTest.apk' | head -n 1) - [ -n "$test_apk" ] || { echo "error: no androidTest APK"; find android/build -name '*.apk'; exit 1; } - cp "$test_apk" "$RUNNER_TEMP/apk/androidTest.apk" + ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.compiler.execution.strategy=in-process \ + :common:uitestBridgeClasspath ls -la "$RUNNER_TEMP/apk" - unzip -l "$RUNNER_TEMP/apk/$APK" | grep -E ' lib/' - name: Prepare test peers and servers working-directory: apps/multiplatform diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt index baf41fbd9c..ffa65ebdc3 100644 --- a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt @@ -240,7 +240,7 @@ class UiScope(val rule: ComposeTestRule) { settle() val after = rule.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().firstOrNull()?.boundsInRoot if (after == before) return false - if (after == null || (if (towardOlder) after.top <= before.top else after.bottom >= before.bottom)) { + if (after != null && (if (towardOlder) after.top <= before.top else after.bottom >= before.bottom)) { throw AssertionError("scrolling the chat toward ${if (towardOlder) "older" else "newer"} messages moved $tag the other way: $before -> $after") } } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt index 3493ea3861..0f8c5132ae 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt @@ -211,7 +211,7 @@ class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { repeat(SCROLL_ANIMATION_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } val after = ui.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().firstOrNull()?.boundsInRoot if (after == before) return false - if (after == null || (if (towardOlder) after.top <= before.top else after.bottom >= before.bottom)) { + if (after != null && (if (towardOlder) after.top <= before.top else after.bottom >= before.bottom)) { throw AssertionError("scrolling the chat toward ${if (towardOlder) "older" else "newer"} messages moved $tag the other way: $before -> $after") } } diff --git a/apps/multiplatform/common/src/uiTestShared/README.md b/apps/multiplatform/common/src/uiTestShared/README.md index 40ce2fb21a..89bfde3973 100644 --- a/apps/multiplatform/common/src/uiTestShared/README.md +++ b/apps/multiplatform/common/src/uiTestShared/README.md @@ -111,6 +111,7 @@ state does not test persistence. - **Timeouts.** The default `poll` timeout is for local UI. Anything that crosses the network (connecting, joining, delivery to a peer) uses `CONNECT_TIMEOUT_MS`; Android runs arm64 code under translation and is slower. Never raise a timeout to make a flaky test pass; find what it is waiting for. +- **Opening a chat** with `ChatListScreen.open()` first waits for app events to settle, because a message that arrives while a chat is being opened can go missing from it (plan §15.5 item 25). A test that is about acting while events arrive clicks the row key directly. - **Group sends**: wait for each member's `#g: member X is connected` before sending, or messages arrive forwarded. ### Platform differences (shared tests run on both) diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReceivedMessagesTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReceivedMessagesTest.kt index ab0d9d7d0a..3a8419338b 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReceivedMessagesTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReceivedMessagesTest.kt @@ -23,6 +23,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue import chat.simplex.common.platform.appPlatform +import chat.simplex.app.ui.harness.KnownAppIssue import org.junit.Test private const val FILLERS = 30 @@ -151,4 +152,18 @@ class ReceivedMessagesTest : UiTest() { } private fun ChatItem.isFrom(name: String) = (chatDir as? CIDirection.GroupRcv)?.groupMember?.memberProfile?.displayName == name + + @KnownAppIssue("15.5 item 25", "all five of bob's messages in the open chat", intermittent = true) + @Test fun openChat_whileMessagesArrive_showsAll() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val id = alice.chatList.chatId("bob") + val texts = (1..5).map { "burst $it" } + texts.forEach { bob types "@alice $it" } + click(Ui.ChatList.Row(id)) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "all five of bob's messages in the open chat") { + chatModel.chatId.value == id && chatModel.chatsContext.chatItems.value.map { it.content.text }.containsAll(texts) + } + } + } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt index 83d13ad4a9..46845cf7ca 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt @@ -82,6 +82,7 @@ class ChatListScreen(private val s: UiScope) { fun open(name: String): ChatScreen { val id = chatId(name) + s.quiesce() s.click(Ui.ChatList.Row(id)) s.poll(what = "chat `$name` to open, or an alert instead") { chatModel.chatId.value == id || AlertManager.shared.hasAlertsShown() || AlertManager.privacySensitive.hasAlertsShown() diff --git a/plans/ui-tests/2026-10-10-desktop-ui-test-dsl-plan.md b/plans/ui-tests/2026-10-10-desktop-ui-test-dsl-plan.md index 367ba52eef..8905325edf 100644 --- a/plans/ui-tests/2026-10-10-desktop-ui-test-dsl-plan.md +++ b/plans/ui-tests/2026-10-10-desktop-ui-test-dsl-plan.md @@ -1675,6 +1675,10 @@ Fixed in the same review: `createGroup_opensChatAndInviteSheet` now checks the o 23. **The SimpleX Lock notice is not offered to users with more than three chats** (`App.kt:94-105`; `LockDesktopTest.lock_noticeOfferedWithManyChats`, marked `@KnownAppIssue("15.5 item 23", intermittent = true)`). `startChat` sets `localUserCreated = true` (`SimpleXAPI.kt:706`) before `getUserChatData` loads the chats (`:707`); that shows the main screen, whose one-shot `LaunchedEffect(showAdvertiseLAAlert)` tests `chatModel.chats.size > 3` while the list is still empty and never runs again. The more chats, the slower the load, so the users the notice is for are the most likely to miss it. +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. **A message that arrives while its chat is being opened is missing from the open chat** (desktop; formerly open flake 15.4.1b). The chat list preview shows the new message with an unread count, but the open chat's items never get it until the chat is reopened. 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, so other tests do not race into it; `ReceivedMessagesTest.openChat_whileMessagesArrive_showsAll` clicks the row while bob's messages are arriving and is marked `@KnownAppIssue("15.5 item 25", intermittent = true)`. + ### 15.6 Catalogue corrections applied from passing tests (exhaustive pass) - 01:34 and 01:8: accepting conditions enables the operators selected in onboarding, not all preset ones. diff --git a/scripts/ci/ui-tests/README.md b/scripts/ci/ui-tests/README.md index 91dfe13377..56bcfe4067 100644 --- a/scripts/ci/ui-tests/README.md +++ b/scripts/ci/ui-tests/README.md @@ -33,7 +33,8 @@ Without `tests`, each platform's tests run in four jobs at once; with `tests`, i | `android-core` | android-emu's `build-core.sh`, when the Android tests run | skipped when reused, about 27 min not | | `bin` | `build-bin.sh`: `exe:simplex-chat` and `exe:uitest-servers` with `-O0 -f uitest`, as `uitest.sh` builds them locally | skipped when reused; to be measured | | `desktop` | one job per shard: `uitest.sh` with the prebuilt CLI and servers (`UITEST_CLI`, `UITEST_SERVERS`) | to be measured | -| `android` | one job per shard: the debug APK and the test APK, the emulator, `uitest-android.sh` | to be measured | +| `android-apk` | the debug APK and the test APK, once per run | to be measured | +| `android` | one job per shard: the host-side bridge, the emulator, `uitest-android.sh` | to be measured | ### Desktop shards @@ -41,11 +42,13 @@ Without `tests`, the desktop tests run in four jobs, one per test package: `chat `uitest.sh` runs the tests in their own user, PID and network namespaces (`unshare -rn --pid`), which Ubuntu 24.04 restricts: the job turns `kernel.apparmor_restrict_unprivileged_userns` off first, as android-emu's core build does for Nix, and checks that `unshare` works. +The desktop app's system theme detector runs `gsettings monitor`, which exits at once without a session bus and then crashes its thread (plan §15.5 item 24), which the harness reports as an app crash. The desktop jobs start a session bus on a filesystem socket (`unix:dir=`), which the tests' network namespace can reach, unlike an abstract socket, and install the GNOME schemas. + ### Android -An emulator runs one test at a time, and the Android tests (about 450) run under ARM translation, likely too long for one job's 6 hours: without `tests` they are split by AndroidJUnitRunner's own sharding (`-e numShards 4 -e shardIndex `), which assigns each test method to a shard by a hash of its name, the same in every pass of `uitest-android.sh`. Each shard job builds the APKs itself, since it compiles the host-side bridge anyway, whose classpath names files in the job's Gradle cache. +An emulator runs one test at a time, and the Android tests (about 450) run under ARM translation, likely too long for one job's 6 hours: without `tests` they are split by AndroidJUnitRunner's own sharding (`-e numShards 4 -e shardIndex `), which assigns each test method to a shard by a hash of its name, the same in every pass of `uitest-android.sh`. The APKs are built once, in `android-apk`; each shard job builds only the host-side bridge, whose classpath names files in the job's Gradle cache, with the Kotlin compiler in-process (building the app and the bridge in one Gradle run ran out of memory). -The emulator is android-emu's (`install-sdk.sh`, `emulator.sh`): Google APIs x86_64, API 34, with KVM, running the app's arm64-v8a code under ARM translation. The APKs are built as android-emu builds its APK (`-Pandroid.injected.build.abi=arm64-v8a`, `:android:assembleFossDebug`), with `:android:assembleFossDebugAndroidTest` and `:common:uitestBridgeClasspath` in the same Gradle run, before `install-sdk.sh` points `ANDROID_HOME` at an SDK that has only the emulator. Without `local.properties` the debug app's id is `chat.simplex.app` (`UITEST_APP_ID`). `uitest.sh --prepare-only` writes the peer template and `build/uitest/env.properties` that the host-side bridge reads, without the desktop build; `uitest-android.sh` then runs with `UITEST_SERIAL` set to the emulator and `ADB` to the SDK's adb, and forwards the bridge's and the servers' ports with `adb reverse`. +The emulator is android-emu's (`install-sdk.sh`, `emulator.sh`): Google APIs x86_64, API 34, with KVM, running the app's arm64-v8a code under ARM translation. The APKs are built as android-emu builds its APK (`-Pandroid.injected.build.abi=arm64-v8a`, `:android:assembleFossDebug`), with `:android:assembleFossDebugAndroidTest`; each shard builds `:common:uitestBridgeClasspath` before `install-sdk.sh` points `ANDROID_HOME` at an SDK that has only the emulator. Without `local.properties` the debug app's id is `chat.simplex.app` (`UITEST_APP_ID`). `uitest.sh --prepare-only` writes the peer template and `build/uitest/env.properties` that the host-side bridge reads, without the desktop build; `uitest-android.sh` then runs with `UITEST_SERIAL` set to the emulator and `ADB` to the SDK's adb, and forwards the bridge's and the servers' ports with `adb reverse`. ## Results