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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+80
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user