test(desktop): add Compose UI smoke test + release .deb launch CI

Fixes #2819 — v1.08.0 .deb crashed on Ubuntu with ManagementFactory
error because the build had no jlink modules() declaration. Current
main already has the fix; this PR adds CI to prevent regressions.

- Add compose.desktop.uiTestJUnit4 dependency
- DesktopLaunchSmokeTest: renders LoginScreen, asserts title text
- build.yml: xvfb for Linux leg so UI test runs on every PR
- smoke-test-desktop.yml: builds release .deb, installs it, launches
  under xvfb, verifies process stays alive 10s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-19 07:09:16 +03:00
parent 0d91245e02
commit cf6541a1e1
5 changed files with 440 additions and 1 deletions
+13 -1
View File
@@ -155,8 +155,20 @@ jobs:
;;
esac
# Compose UI smoke test (DesktopLaunchSmokeTest) uses Skiko which needs
# a display server on Linux. xvfb provides a virtual framebuffer.
- name: Install xvfb (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Test + Build Desktop (gradle)
run: ./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
$CMD
fi
# jpackage pins libicu to the build host's version (libicu74 on
# ubuntu-24.04). Rewrite the .deb so testers on other Debian/Ubuntu
+174
View File
@@ -0,0 +1,174 @@
# ⚠️ WARNING: Before merging, change the trigger back to workflow_dispatch only.
# The pull_request trigger is temporary so we can validate this workflow on the PR
# and attach run results as proof-of-work. Production trigger should be:
#
# on:
# workflow_dispatch:
# pull_request:
# paths:
# - 'desktopApp/build.gradle.kts'
# - 'desktopApp/compose-rules.pro'
# - '.github/workflows/smoke-test-desktop.yml'
#
name: Desktop Smoke Test
on:
workflow_dispatch:
pull_request:
permissions:
contents: read
concurrency:
group: smoke-desktop-${{ github.ref }}
cancel-in-progress: true
jobs:
# -------------------------------------------------------------------------
# 1) Compose UI test — verifies the composable tree renders (login screen)
# under the dev classpath. Catches missing string resources, broken
# composables, and basic dependency-graph issues.
# -------------------------------------------------------------------------
compose-ui-test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: true
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Run desktop tests (including UI smoke test)
run: xvfb-run --auto-servernum ./gradlew :desktopApp:test
# -------------------------------------------------------------------------
# 2) Release .deb build + launch — builds the ProGuard'd, jlink'd .deb
# package, installs it, and verifies the process stays alive for 10s.
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
# (java.management, java.prefs), and native lib bundling issues.
# -------------------------------------------------------------------------
release-deb-launch:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: true
- name: Cache vlc-setup downloads
uses: actions/cache@v4
with:
path: ~/.gradle/vlcSetup
key: vlcsetup-Linux-${{ hashFiles('desktopApp/build.gradle.kts') }}
restore-keys: |
vlcsetup-Linux-
- name: Pre-fetch VLC + UPX archives
env:
VLC_VERSION: "3.0.20"
UPX_VERSION: "4.2.4"
run: |
set -euo pipefail
DEST="$HOME/.gradle/vlcSetup"
mkdir -p "$DEST"
fetch() {
local url="$1" out="$2"
if [[ -s "$out" ]]; then echo "cached: $out"; return 0; fi
echo "fetching: $url"
curl -fL --retry 10 --retry-delay 5 --retry-all-errors \
--retry-max-time 900 --connect-timeout 30 \
-o "$out.part" "$url"
mv "$out.part" "$out"
}
fetch "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/${VLC_VERSION}/vlc-plugins-linux-${VLC_VERSION}.jar" \
"$DEST/vlc-${VLC_VERSION}.jar"
fetch "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-amd64_linux.tar.xz" \
"$DEST/upx-${UPX_VERSION}.tar.xz"
- name: Install xvfb + packaging deps
run: sudo apt-get update && sudo apt-get install -y xvfb fakeroot
- name: Build release .deb
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
max_attempts: 2
timeout_minutes: 20
command: ./gradlew --no-daemon :desktopApp:packageReleaseDeb
- name: Relax libicu dependency
run: |
set -euo pipefail
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
- name: Install .deb
run: |
set -euo pipefail
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb
echo "Installed files:"
dpkg -L amethyst | head -30
- name: Smoke test — app launches and stays alive
run: |
set -euo pipefail
# Find the launcher binary
LAUNCHER=$(find /opt -name "Amethyst" -type f -executable 2>/dev/null | head -1)
if [[ -z "$LAUNCHER" ]]; then
# Fallback: search dpkg file list
LAUNCHER=$(dpkg -L amethyst | grep -E '/bin/[Aa]methyst$' | head -1)
fi
if [[ -z "$LAUNCHER" ]]; then
echo "FAIL: could not find Amethyst launcher binary"
dpkg -L amethyst
exit 1
fi
echo "Launcher: $LAUNCHER"
# Launch under xvfb with a timeout safety net
xvfb-run --auto-servernum timeout 30 "$LAUNCHER" &
APP_PID=$!
echo "PID: $APP_PID"
# Wait 10s — if the process is still alive, the app launched successfully
sleep 10
if kill -0 "$APP_PID" 2>/dev/null; then
echo "PASS: Application launched and stayed alive for 10s"
kill "$APP_PID" || true
wait "$APP_PID" 2>/dev/null || true
else
wait "$APP_PID" 2>/dev/null
EXIT_CODE=$?
echo "FAIL: Application exited with code $EXIT_CODE within 10s"
exit 1
fi
- name: Upload .deb artifact (for manual testing)
if: always()
uses: actions/upload-artifact@v7
with:
name: Release DEB (smoke-tested)
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
+3
View File
@@ -82,6 +82,9 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.okhttp)
// Compose UI testing (createComposeRule / onNodeWithText / etc.)
testImplementation(compose.desktop.uiTestJUnit4)
}
compose.desktop {
@@ -0,0 +1,170 @@
# Plan: Fix Desktop .deb Launch Crash + CI Smoke Test
**Issue:** [#2819](https://github.com/vitorpamplona/amethyst/issues/2819)
**Branch:** `fix/desktop-deb-launch-crash`
**Date:** 2026-05-19
## Problem
v1.08.0 `.deb` crashes on Ubuntu 24.04 with `java/lang/management/ManagementFactory` error.
### Root Cause
v1.08.0's `build.gradle.kts` had **no `modules()` declaration** — jlink auto-detection missed `java.management`. Current `main` already has the fix (`modules("java.management", ...)` + full ProGuard rules), but **no CI step verifies the packaged app actually launches**.
### Verification
- `proguardReleaseJars` passes on current main (verified locally)
- Can't build `.deb` on macOS (jpackage requires Linux)
- Can't run `.deb` on macOS (dpkg/Linux binary)
- **Need CI to build + launch-test the .deb**
---
## Phase 1: Release .deb Build + Launch Smoke Test
New `workflow_dispatch` workflow that builds the release .deb on ubuntu and verifies it launches.
### Step 1: Add `compose.desktop.uiTestJUnit4` dependency
**File:** `desktopApp/build.gradle.kts`
```kotlin
dependencies {
// ... existing ...
testImplementation(compose.desktop.uiTestJUnit4)
}
```
This gives us `createComposeRule()` for in-process Compose UI tests. Requires xvfb on Linux (Skiko needs a display server).
### Step 2: Write a Compose UI smoke test
**File:** `desktopApp/src/jvmTest/kotlin/.../DesktopLaunchSmokeTest.kt`
Test that the app's root composable renders the login screen (default state = LoggedOut):
```kotlin
class DesktopLaunchSmokeTest {
@get:Rule
val compose = createComposeRule()
@Test
fun loginScreenRenders() {
compose.setContent {
// Minimal App shell — just enough to prove the dependency graph loads:
// Jackson, SQLite, secp256k1, ManagementFactory, Compose, Skiko
MaterialTheme {
LoginScreen(
accountManager = AccountManager.create(),
onLoginSuccess = {},
)
}
}
// If we get here without NoClassDefFoundError / UnsatisfiedLinkError,
// the ProGuard rules and jlink modules are correct.
compose.onNodeWithText("Amethyst", substring = true).assertExists()
}
}
```
This exercises:
- Compose + Skiko rendering (login screen paints)
- Jackson (AccountManager loads account storage)
- SQLite (if local relay store initializes)
- secp256k1 JNI (crypto library loaded)
- `java.management` module (ManagementFactory via coordinator — may need to poke it explicitly)
### Step 3: Run existing tests under xvfb in `build.yml`
**File:** `.github/workflows/build.yml`
The Linux leg already runs `:desktopApp:test`. Once we add the UI test, it needs xvfb:
```yaml
# In the build-desktop job, Linux leg
- name: Install xvfb (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Test + Build Desktop (gradle)
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest ... :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
$CMD
fi
```
### Step 4: New workflow — Release .deb smoke test (manual trigger)
**File:** `.github/workflows/smoke-test-desktop.yml`
```yaml
name: Desktop Smoke Test
on:
workflow_dispatch:
# Also run on PRs that touch desktop packaging
pull_request:
paths:
- 'desktopApp/build.gradle.kts'
- 'desktopApp/compose-rules.pro'
- '.github/workflows/smoke-test-desktop.yml'
jobs:
smoke-test-linux-deb:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- Checkout
- Setup JDK 21 (Zulu)
- Setup Gradle (cache)
- Pre-fetch VLC + UPX (reuse from build.yml)
- Install xvfb + rpm tooling
- Build release .deb:
./gradlew :desktopApp:packageReleaseDeb
- Relax libicu dependency
- Install .deb:
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb
- Launch smoke test:
xvfb-run --auto-servernum timeout 20 /opt/amethyst/bin/Amethyst &
PID=$!; sleep 10
kill -0 $PID && echo "PASS" && kill $PID || exit 1
- Run Compose UI tests under xvfb:
xvfb-run --auto-servernum ./gradlew :desktopApp:test
```
This tests TWO things:
1. **Installed .deb actually launches** (process survives 10s = no crash)
2. **Compose UI test passes** (login screen renders correctly)
### Step 5: Verify .deb install path
Need to confirm jpackage installs to `/opt/amethyst/bin/Amethyst`. Check by:
- Looking at jpackage docs (default for Linux is `/opt/<packageName>`)
- Or: `dpkg -L amethyst` after install in CI to list files
---
## Implementation Checklist
| # | Task | File(s) |
|---|------|---------|
| 1 | Add `compose.desktop.uiTestJUnit4` test dep | `desktopApp/build.gradle.kts` |
| 2 | Add test tag to LoginScreen title | `desktopApp/.../LoginScreen.kt` |
| 3 | Write `DesktopLaunchSmokeTest` | `desktopApp/src/jvmTest/.../DesktopLaunchSmokeTest.kt` |
| 4 | Add xvfb to Linux leg of `build.yml` | `.github/workflows/build.yml` |
| 5 | Create `smoke-test-desktop.yml` workflow | `.github/workflows/smoke-test-desktop.yml` |
| 6 | Verify install path in first CI run | manual check |
| 7 | `spotlessApply` | all modified files |
---
## Unanswered Questions
1. **Install path** — Is it `/opt/amethyst/bin/Amethyst` or `/opt/Amethyst/bin/Amethyst`? (case-sensitive; `packageName = "Amethyst"`)
2. **AccountManager.create()** in test — does it need filesystem access? May need temp dir or mock
3. **VLC init**`Main.kt` pre-inits VLC on a background thread. Smoke test should skip this or tolerate VLC absence on CI
4. **xvfb + Skiko compatibility** — JetBrains uses xvfb in their own CI (skiko repo), so should work, but need to verify with our Compose version (1.10.3)
5. **Should we also smoke-test the release DMG on macos-14?** (open the DMG, launch, check process alive)
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import io.mockk.mockk
import org.junit.Rule
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
/**
* Smoke test that verifies the desktop app's login screen renders.
*
* This exercises the full dependency graph that a fresh launch hits:
* Compose + Skiko rendering, string resources, Material3 theming,
* and AccountManager construction (Jackson, filesystem access).
*
* On Linux CI this requires xvfb (Skiko needs a display server).
* On macOS/Windows it runs natively.
*/
class DesktopLaunchSmokeTest {
@get:Rule
val compose = createComposeRule()
private lateinit var tempDir: File
private lateinit var manager: AccountManager
@BeforeTest
fun setup() {
tempDir = createTempDirectory("smoke-test").toFile()
File(tempDir, ".amethyst").mkdirs()
val storage = mockk<SecureKeyStorage>(relaxed = true)
manager = AccountManager(storage, tempDir)
}
@AfterTest
fun teardown() {
tempDir.deleteRecursively()
}
@Test
fun loginScreenRenders() {
compose.setContent {
MaterialTheme {
LoginScreen(
accountManager = manager,
onLoginSuccess = {},
)
}
}
compose.onNodeWithText("Welcome to Amethyst").assertExists()
compose.onNodeWithText("A Nostr client for desktop").assertExists()
}
}