Compare commits

...

2 Commits

Author SHA1 Message Date
Claude
bc390fcb7b test: add opt-in live home-feed screenshot capture (desktop)
Drives the real App() to a ViewOnly logged-in state against live relays and
captures the home/following feed — the data-driven counterpart to the showcase
tests. Gated on -Pamethyst.live.capture=true (forwarded to the test JVM) so CI
and offline runs stay deterministic; skipped otherwise.

Documents the live-capture pattern in the feature-screenshots skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERVmp5BuJDCzSHiaV662R3
2026-06-21 20:53:08 +00:00
Claude
735ea9039d feat: add headless screenshot & GIF capture for desktop and Android UI
Adds two harnesses so contributors (and agents) can render any composable
into a PNG screenshot or a short animated-GIF clip to highlight a feature,
with no emulator, device, or display server tooling.

Desktop (zero new deps): rides the existing createComposeRule test infra;
PNG/GIF encoding via the JDK's ImageIO. Helpers in desktopApp jvmTest
(ScreenshotCapture.kt, ImageWriters.kt) + FeatureShowcaseDesktopTest as
template/proof. Runs under the existing `xvfb-run :desktopApp:test`.

Android (Robolectric + Roborazzi, both permissively licensed): renders
through the real AmethystTheme. FeatureShowcaseAndroidTest as template/proof,
run via `:amethyst:recordRoborazziPlayDebug`. Pinned to @Config(sdk=36)
because Robolectric has no API-37 jar for this repo's compileSdk 37, and to a
stock Application stub so the real Amethyst.onCreate isn't booted.

Adds a feature-screenshots skill documenting the workflow and gotchas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERVmp5BuJDCzSHiaV662R3
2026-06-21 20:13:47 +00:00
9 changed files with 834 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
---
name: feature-screenshots
description: Capture screenshots and short animated-GIF clips of Amethyst UI to highlight a feature you are developing — no emulator, no device, no display. Use when asked to "show", "screenshot", "record", "capture", or "demo" what a feature looks like, when you want to attach a visual to a PR/answer, or when you finish a UI change and want to surface proof of how it looks. Covers both the Desktop harness (Compose UI test + ImageIO, zero new deps) and the Android harness (Robolectric + Roborazzi). Complements compose-expert (where shared composables live) and verify (which confirms behavior, not appearance).
---
# Feature Screenshots & Clips
Render any composable to a **PNG** (screenshot) or a short **animated GIF** (clip)
headlessly, then surface it with the `SendUserFile` tool. Two harnesses share the
same idea — render the real composable, drive it into the state worth showing,
capture a frame (or a sequence of frames) — and both run in CI's headless
container with no emulator and no extra system tooling.
Artifacts always land in **`<module>/build/screenshots/`** (gitignored, under `build/`).
## When to Use
Auto-invoke when the user asks to *show / screenshot / record / capture / demo* a
feature, wants a visual for a PR or answer, or when you've just finished a UI
change and a picture would prove it. For behavioral verification (does it work?)
use `verify` instead — this skill is about appearance.
## Desktop harness (no new dependencies)
Lives in `desktopApp/src/jvmTest/.../capture/`. Rides the existing
`createComposeRule` test infra; PNG/GIF encoding is pure JDK `ImageIO`.
1. Write a test next to `FeatureShowcaseDesktopTest.kt` (the template):
```kotlin
compose.setContent { MaterialTheme { MyFeature(...) } }
compose.onRoot().saveScreenshot("desktop-my-feature") // -> PNG
```
For a clip, collect frames across interactions and call `writeAnimatedGif(...)`.
2. Run headless (Skiko needs a display, hence xvfb):
```bash
xvfb-run --auto-servernum ./gradlew :desktopApp:test --tests '*FeatureShowcaseDesktopTest*'
```
3. Helpers: `SemanticsNodeInteraction.saveScreenshot(name)`,
`.toBufferedImage()`, and `writeAnimatedGif(frames, file, delayMs, loop)` in
`ScreenshotCapture.kt` / `ImageWriters.kt`.
## Android harness (Robolectric + Roborazzi)
Lives in `amethyst/src/test/.../screenshots/`. Renders through the real
`AmethystTheme` — closest to what ships on a phone.
1. Write a test next to `FeatureShowcaseAndroidTest.kt` (the template):
```kotlin
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], application = Application::class) // see gotchas
class MyFeatureShots {
@get:Rule val compose = createComposeRule()
@Test fun shot() {
compose.setContent { AmethystTheme(ThemeType.LIGHT) { MyFeature() } }
compose.onRoot().captureRoboImage("build/screenshots/android-my-feature.png")
}
}
```
GIF: `compose.onRoot().captureRoboGif(compose, "build/screenshots/x.gif") { ...interactions... }`.
2. Run the Roborazzi record task (it sets record mode + runs the tests):
```bash
./gradlew :amethyst:recordRoborazziPlayDebug --tests '*MyFeatureShots*'
```
### Android gotchas (learned the hard way)
- **`@Config(sdk = [36])` is mandatory.** The repo is `compileSdk 37`, but
Robolectric ships framework jars only up to **API 36** — an unpinned test fails
to resolve an SDK. Bump this when Robolectric adds a newer jar.
- **`application = Application::class`.** Without it Robolectric boots the real
`Amethyst` Application, whose `onCreate` (WorkManager, Tor, Coil…) throws.
A stock `Application` stub renders UI fine.
- **`captureRoboGif` does not create the output dir** (the PNG path does); ensure
`build/screenshots/` exists first (a `@Before { File("build/screenshots").mkdirs() }`).
- `unitTests.isIncludeAndroidResources = true` must stay on in `amethyst/build.gradle.kts`.
## Live, data-driven captures (real relays)
To screenshot a screen that needs real network data (e.g. a specific pubkey's
home feed), drive the real `App()` composable to a logged-in state against live
relays instead of rendering an isolated composable. See
`HomeFeedLiveCaptureTest.kt` (desktop) as the template:
- Pre-seed a `ViewOnly` `AccountInfo(npub, SignerType.ViewOnly)` via
`accountManager.accountStorage`, then drive `App(layoutMode = SINGLE_PANE, …)`
(its default screen is the Home feed) with a **real**
`DesktopRelayConnectionManager(DesktopHttpClient(...Tor OFF...))` and
`skipStartupRelayBootstrap = false`.
- Wait for `AccountState.LoggedIn`, then `compose.waitUntil { localCache.noteCount() > N }`
so the feed has pulled notes, settle a couple of frames, then `saveScreenshot`.
- These tests hit the network, so they're **opt-in**: gated on
`-Pamethyst.live.capture=true` (forwarded to the test JVM in
`desktopApp/build.gradle.kts`) and skipped otherwise. Relays must be reachable.
```bash
xvfb-run --auto-servernum ./gradlew :desktopApp:test \
--tests '*HomeFeedLiveCaptureTest*' -Pamethyst.live.capture=true
```
## Surfacing the result
After the artifact is written, send it to the user:
```
SendUserFile(files = ["amethyst/build/screenshots/android-my-feature.png"], status = "normal")
```
Prefer `status: "proactive"` only when the user is away and the image is the deliverable.
## Don't
- Don't reach for `ffmpeg` / `scrot` / `x11grab` — they aren't installed; the
in-JVM PNG/GIF path is the supported route.
- Don't render the whole running app to "screenshot a screen" — render the
specific composable in isolation, as the templates do.

View File

@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
alias(libs.plugins.roborazzi)
}
fun getCurrentBranch(workingDir: java.io.File): String =
@@ -276,6 +277,9 @@ android {
testOptions {
unitTests.isReturnDefaultValues = true
// Robolectric (used by the Roborazzi screenshot tests) needs the merged
// Android resources/assets/manifest on the unit-test classpath.
unitTests.isIncludeAndroidResources = true
// Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android")
// find the desktop-host build of our Arti JNI shim. The Android .so
// variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded
@@ -496,6 +500,15 @@ dependencies {
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
// Screenshot / GIF capture of Android composables, no emulator (Robolectric + Roborazzi).
// Renders shared/Android composables to PNGs and animated GIFs under build/screenshots/.
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.ui.test.junit4)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.junit.rule)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit.ktx)

View File

@@ -0,0 +1,133 @@
/*
* 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.screenshots
import android.app.Application
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboGif
import com.github.takahirom.roborazzi.captureRoboImage
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Android-flavored feature captures via Robolectric + Roborazzi — no emulator, no device.
* Renders composables through the real [AmethystTheme] and writes PNGs / animated GIFs.
*
* Run with:
* ```
* ./gradlew :amethyst:recordRoborazziPlayDebug --tests '*FeatureShowcaseAndroidTest*'
* ```
* Artifacts land in `amethyst/build/screenshots/`.
*
* compileSdk is 37, but Robolectric only ships framework jars up to API 36, so the
* tests are pinned with `@Config(sdk = [36])`. NATIVE graphics mode is required for
* Roborazzi to rasterize real pixels.
*
* This is both the executable proof of the harness and the copy-paste template for
* highlighting a new Android feature: render it inside [AmethystTheme], then
* `captureRoboImage` a frame or `captureRoboGif` a sequence.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// Stock Application stub — skip the real Amethyst.onCreate (WorkManager, Tor, Coil, …)
// which a headless screenshot render neither needs nor can boot.
@Config(sdk = [36], application = Application::class)
class FeatureShowcaseAndroidTest {
@get:Rule
val compose = createComposeRule()
// captureRoboImage creates parent dirs itself, but the GIF writer does not — ensure it exists.
@Before
fun ensureOutputDir() {
java.io.File("build/screenshots").mkdirs()
}
/** Single-frame screenshot of a themed composable. */
@Test
fun demo_screenshot() {
compose.setContent {
AmethystTheme(ThemeType.LIGHT) {
CounterDemo()
}
}
compose.onNodeWithText("Tap me").assertExists()
compose.onRoot().captureRoboImage("build/screenshots/android-demo.png")
}
/** Animated-GIF clip: Roborazzi records a frame after each interaction in the block. */
@Test
fun demo_clip() {
compose.setContent {
AmethystTheme(ThemeType.LIGHT) {
CounterDemo()
}
}
compose.onRoot().captureRoboGif(compose, "build/screenshots/android-demo-clip.gif") {
repeat(3) {
compose.onNodeWithText("Tap me").performClick()
}
}
}
}
@Composable
private fun CounterDemo() {
var count by remember { mutableIntStateOf(0) }
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Taps: $count")
Button(onClick = { count++ }, modifier = Modifier.padding(top = 16.dp)) {
Text("Tap me")
}
}
}
}

View File

@@ -27,6 +27,15 @@ kotlin {
jvmToolchain(21)
}
// Forward opt-in capture flags from the Gradle invocation into the test JVM so the
// live screenshot tests (e.g. HomeFeedLiveCaptureTest) can be switched on with
// `-Pamethyst.live.capture=true` and optionally `-Pamethyst.screenshot.dir=...`.
tasks.withType<Test>().configureEach {
listOf("amethyst.live.capture", "amethyst.screenshot.dir").forEach { key ->
(project.findProperty(key) as? String)?.let { systemProperty(key, it) }
}
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.jetbrains.compose.material3)

View File

@@ -0,0 +1,146 @@
/*
* 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.capture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
import io.mockk.mockk
import org.junit.Rule
import java.awt.image.BufferedImage
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Example showcase captures. Run headless with:
* ```
* xvfb-run --auto-servernum ./gradlew :desktopApp:test --tests '*FeatureShowcaseDesktopTest*'
* ```
* Artifacts land in `desktopApp/build/screenshots/`.
*
* This doubles as the executable proof that the capture harness works, and as the
* copy-paste template a contributor/agent follows when highlighting a new feature:
* render the real composable, drive it into the state worth showing, then
* [saveScreenshot] a frame (PNG) or [writeAnimatedGif] a sequence of frames (clip).
*/
class FeatureShowcaseDesktopTest {
@get:Rule
val compose = createComposeRule()
private lateinit var tempDir: File
private lateinit var manager: AccountManager
@BeforeTest
fun setup() {
tempDir = createTempDirectory("screenshot-showcase").toFile()
File(tempDir, ".amethyst").mkdirs()
val storage = mockk<SecureKeyStorage>(relaxed = true)
manager = AccountManager(storage, tempDir)
}
@AfterTest
fun teardown() {
tempDir.deleteRecursively()
}
/** Captures a real shared screen to a PNG — the single-frame "screenshot" path. */
@Test
fun loginScreen_screenshot() {
compose.setContent {
MaterialTheme {
LoginScreen(
accountManager = manager,
onLoginSuccess = {},
)
}
}
compose.onNodeWithText("Welcome to Amethyst").assertExists()
val png = compose.onRoot().saveScreenshot("desktop-login-screen")
assertTrue(png.exists() && png.length() > 0, "expected a non-empty PNG at $png")
}
/**
* Captures frames across an interaction and stitches them into an animated GIF —
* the "video clip" path. Uses a self-contained composable so the demo never breaks
* when a real screen changes; on a real feature, render that feature instead.
*/
@Test
fun counter_clip() {
compose.setContent {
MaterialTheme {
CounterDemo()
}
}
val frames = mutableListOf<BufferedImage>()
frames += compose.onRoot().toBufferedImage()
repeat(3) {
compose.onNodeWithText("Tap me").performClick()
compose.waitForIdle()
frames += compose.onRoot().toBufferedImage()
}
val gif = screenshotFile("desktop-counter-clip.gif")
writeAnimatedGif(frames, gif, delayMs = 600)
assertTrue(gif.exists() && gif.length() > 0, "expected a non-empty GIF at $gif")
}
}
@androidx.compose.runtime.Composable
private fun CounterDemo() {
var count by remember { mutableIntStateOf(0) }
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Taps: $count", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { count++ }, modifier = Modifier.padding(top = 16.dp)) {
Text("Tap me")
}
}
}
}

View File

@@ -0,0 +1,216 @@
/*
* 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.capture
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onRoot
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.model.account.SignerType
import com.vitorpamplona.amethyst.commons.tor.ITorManager
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.desktop.App
import com.vitorpamplona.amethyst.desktop.LaunchTestOverrides
import com.vitorpamplona.amethyst.desktop.LayoutMode
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.relay.LocalRelayStore
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
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
/**
* Live capture of the desktop HOME (following) feed for a given pubkey, logged in
* read-only (ViewOnly), against REAL public relays. This is the data-driven
* counterpart to [FeatureShowcaseDesktopTest] — it proves the harness can grab a
* real screen with real network content, not just an isolated composable.
*
* Because it depends on live relays it is OPT-IN: pass `-Pamethyst.live.capture=true`
* (or set the `AMETHYST_LIVE_CAPTURE` env var). Without it the test is skipped so CI
* and offline runs stay deterministic.
*
* Run:
* ```
* xvfb-run --auto-servernum ./gradlew :desktopApp:test \
* --tests '*HomeFeedLiveCaptureTest*' -Pamethyst.live.capture=true
* ```
* Artifact: `desktopApp/build/screenshots/desktop-home-feed.png`.
*/
class HomeFeedLiveCaptureTest {
@get:Rule
val compose = createComposeRule()
private lateinit var tempDir: File
private lateinit var storage: SecureKeyStorage
private lateinit var harnessScope: CoroutineScope
private val liveEnabled: Boolean =
System.getProperty("amethyst.live.capture")?.toBoolean() == true ||
System.getenv("AMETHYST_LIVE_CAPTURE")?.toBoolean() == true
@BeforeTest
fun setup() {
tempDir = createTempDirectory("home-feed-live-capture").toFile()
File(tempDir, ".amethyst").mkdirs()
storage = mockk(relaxed = true)
coEvery { storage.getPrivateKey(any()) } returns null
harnessScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}
@AfterTest
fun teardown() {
harnessScope.cancel()
tempDir.deleteRecursively()
}
@Test
fun captureHomeFeed() {
if (!liveEnabled) {
println("HomeFeedLiveCaptureTest skipped — pass -Pamethyst.live.capture=true to run.")
return
}
val pubKeyHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val npub = NPub.create(pubKeyHex)
// Pre-seed a ViewOnly account so App()'s loadSavedAccount logs in on startup.
val accountManager = AccountManager(storage, tempDir)
runBlocking {
accountManager.accountStorage.saveAccount(AccountInfo(npub = npub, signerType = SignerType.ViewOnly))
accountManager.accountStorage.setCurrentAccount(npub)
}
val localCache = DesktopLocalCache()
val localRelayStore = LocalRelayStore(scope = harnessScope, homeDir = tempDir)
val torManager = OffTorManager()
// REAL relay manager over OkHttp (Tor OFF) — connects to live default relays.
val httpClient =
DesktopHttpClient(
torManager = torManager,
shouldUseTorForRelay = { false },
torTypeProvider = { TorType.OFF },
scope = harnessScope,
).also { DesktopHttpClient.setInstance(it) }
val relayManager = DesktopRelayConnectionManager(httpClient)
compose.setContent {
MaterialTheme {
App(
layoutMode = LayoutMode.SINGLE_PANE, // default screen is the Home feed
onLayoutModeChange = {},
deckState = DeckState(harnessScope),
workspaceManager = WorkspaceManager(harnessScope),
accountManager = accountManager,
showComposeDialog = false,
showAppDrawer = false,
onShowComposeDialog = {},
onShowReplyDialog = {},
onDismissComposeDialog = {},
onDismissAppDrawer = {},
onShowAppDrawer = {},
replyToNote = null,
torManager = torManager,
torTypeFlow = MutableStateFlow(TorType.OFF),
externalPortFlow = MutableStateFlow(9050),
initialTorSettings = OFF_TOR_SETTINGS,
testOverrides =
LaunchTestOverrides(
localCache = localCache,
relayManager = relayManager,
localRelayStore = localRelayStore,
// Run the production addDefaultRelays/connect path so the
// home-feed subscriptions actually reach live relays.
skipStartupRelayBootstrap = false,
torSettingsOverride = OFF_TOR_SETTINGS,
),
)
}
}
// Wait for login, then for the following feed to pull notes off live relays.
compose.waitUntil(timeoutMillis = 20_000) {
accountManager.accountState.value is AccountState.LoggedIn
}
compose.waitUntil(timeoutMillis = 45_000) { localCache.noteCount() > 8 }
// Let a couple of frames settle (avatars, layout) before the grab.
compose.waitForIdle()
runBlocking { kotlinx.coroutines.delay(2_500) }
compose.waitForIdle()
val png = compose.onRoot().saveScreenshot("desktop-home-feed")
println("Home feed capture: $png (${png.length()} bytes, notes=${localCache.noteCount()})")
}
companion object {
private val OFF_TOR_SETTINGS =
TorSettings(
torType = TorType.OFF,
externalSocksPort = 9050,
onionRelaysViaTor = false,
dmRelaysViaTor = false,
newRelaysViaTor = false,
trustedRelaysViaTor = false,
urlPreviewsViaTor = false,
profilePicsViaTor = false,
imagesViaTor = false,
videosViaTor = false,
moneyOperationsViaTor = false,
nip05VerificationsViaTor = false,
mediaUploadsViaTor = false,
)
}
}
/** Tor stand-in that reports Off forever (no real kmp-tor runtime in a headless test). */
private class OffTorManager : ITorManager {
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
override val activePortOrNull: StateFlow<Int?> = MutableStateFlow<Int?>(null).asStateFlow()
override suspend fun dormant() = Unit
override suspend fun active() = Unit
override suspend fun newIdentity() = Unit
}

View File

@@ -0,0 +1,121 @@
/*
* 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.capture
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageTypeSpecifier
import javax.imageio.metadata.IIOMetadataNode
import javax.imageio.stream.FileImageOutputStream
/** Writes a single [BufferedImage] to [file] as PNG using the JDK's ImageIO. */
fun writePng(
image: BufferedImage,
file: File,
) {
file.parentFile?.mkdirs()
require(ImageIO.write(image, "png", file)) { "No PNG ImageIO writer available" }
}
/**
* Stitches [frames] into an animated GIF at [file] — the dependency-free stand-in
* for a short feature "video" clip. Uses only `javax.imageio`'s GIF writer, so it
* works in any JVM with no ffmpeg / native tooling.
*
* @param delayMs per-frame display time in milliseconds (rounded to GIF's 10ms unit).
* @param loop when true the clip repeats forever (NETSCAPE2.0 loop extension).
*/
fun writeAnimatedGif(
frames: List<BufferedImage>,
file: File,
delayMs: Int = 700,
loop: Boolean = true,
) {
require(frames.isNotEmpty()) { "Cannot write an animated GIF with no frames" }
file.parentFile?.mkdirs()
// GIF is a paletted RGB format — normalise every frame to TYPE_INT_RGB so the
// metadata we attach matches the pixels and ARGB frames don't trip the writer.
val rgbFrames = frames.map { it.toType(BufferedImage.TYPE_INT_RGB) }
val writer =
ImageIO.getImageWritersBySuffix("gif").asSequence().firstOrNull()
?: error("No GIF ImageIO writer available")
val params = writer.defaultWriteParam
val imageType = ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB)
val metadata = writer.getDefaultImageMetadata(imageType, params)
val format = metadata.nativeMetadataFormatName
val root = metadata.getAsTree(format) as IIOMetadataNode
childNode(root, "GraphicControlExtension").apply {
setAttribute("disposalMethod", "none")
setAttribute("userInputFlag", "FALSE")
setAttribute("transparentColorFlag", "FALSE")
setAttribute("delayTime", (delayMs / 10).coerceAtLeast(1).toString())
setAttribute("transparentColorIndex", "0")
}
if (loop) {
// NETSCAPE2.0 application extension = "loop forever" (loop count 0).
val appExtensions = childNode(root, "ApplicationExtensions")
val appNode =
IIOMetadataNode("ApplicationExtension").apply {
setAttribute("applicationID", "NETSCAPE")
setAttribute("authenticationCode", "2.0")
userObject = byteArrayOf(0x1, 0, 0)
}
appExtensions.appendChild(appNode)
}
metadata.setFromTree(format, root)
FileImageOutputStream(file).use { out ->
writer.output = out
writer.prepareWriteSequence(null)
rgbFrames.forEach { writer.writeToSequence(IIOImage(it, null, metadata), params) }
writer.endWriteSequence()
}
writer.dispose()
}
/** Returns this node's first child named [name], creating and appending it if absent. */
private fun childNode(
parent: IIOMetadataNode,
name: String,
): IIOMetadataNode {
for (i in 0 until parent.length) {
val node = parent.item(i)
if (node.nodeName.equals(name, ignoreCase = true)) return node as IIOMetadataNode
}
return IIOMetadataNode(name).also { parent.appendChild(it) }
}
/** Returns this image converted to [targetType] (no-op when it already matches). */
private fun BufferedImage.toType(targetType: Int): BufferedImage {
if (type == targetType) return this
val converted = BufferedImage(width, height, targetType)
val g = converted.createGraphics()
g.drawImage(this, 0, 0, null)
g.dispose()
return converted
}

View File

@@ -0,0 +1,75 @@
/*
* 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.capture
import androidx.compose.ui.graphics.toAwtImage
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.captureToImage
import java.awt.image.BufferedImage
import java.io.File
/**
* Dependency-free screenshot / animated-GIF capture for the Compose Desktop app.
*
* The whole point of this harness is to let a contributor (or an automated agent)
* render any shared composable into a PNG — or a short animated GIF "clip" — that
* highlights a feature, without an emulator, a display, or any third-party library.
* It rides on the existing [androidx.compose.ui.test.junit4.v2.createComposeRule]
* test infrastructure, so it runs headless under `xvfb-run ./gradlew :desktopApp:test`
* exactly like the smoke test does.
*
* Output lands in [screenshotDir] (default `desktopApp/build/screenshots/`, override
* with `-Damethyst.screenshot.dir=...`). PNG encoding uses the JDK's bundled ImageIO
* — no Roborazzi/Paparazzi/ffmpeg required on the desktop side.
*
* Usage from a test that already has a `compose` rule and rendered content:
* ```
* compose.onRoot().saveScreenshot("home-feed") // single frame -> PNG
* ```
* For a clip, collect frames across interactions and stitch them:
* ```
* val frames = mutableListOf<BufferedImage>()
* frames += compose.onRoot().toBufferedImage()
* compose.onNodeWithText("Post").performClick(); compose.waitForIdle()
* frames += compose.onRoot().toBufferedImage()
* writeAnimatedGif(frames, screenshotFile("compose-post.gif"))
* ```
*/
val screenshotDir: File by lazy {
File(System.getProperty("amethyst.screenshot.dir") ?: "build/screenshots")
.apply { mkdirs() }
}
/** Resolves a capture artifact path inside [screenshotDir], creating parents. */
fun screenshotFile(fileName: String): File = File(screenshotDir, fileName).apply { parentFile?.mkdirs() }
/** Captures this node (use `compose.onRoot()` for the whole window) into an AWT image. */
fun SemanticsNodeInteraction.toBufferedImage(): BufferedImage = captureToImage().toAwtImage()
/**
* Captures this node into `<[screenshotDir]>/<[name]>.png` and returns the written file.
* Pass `compose.onRoot()` to grab the entire rendered surface.
*/
fun SemanticsNodeInteraction.saveScreenshot(name: String): File {
val file = screenshotFile("$name.png")
writePng(toBufferedImage(), file)
return file
}

View File

@@ -55,6 +55,8 @@ negentropyKmp = "v1.0.2"
netUrlencoderLibVersion = "1.6.0"
navigationCompose = "2.9.8"
okhttp = "5.4.0"
roborazzi = "1.64.0"
robolectric = "4.16.1"
osmdroid = "6.1.20"
runner = "1.7.0"
secp256k1KmpJniAndroid = "0.23.0"
@@ -192,6 +194,10 @@ mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "moc
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
negentropy-kmp = { module = "com.vitorpamplona.negentropy:kmp-negentropy", version.ref = "negentropyKmp" }
net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" }
roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" }
roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" }
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
@@ -236,3 +242,4 @@ androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.lib
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
googleKsp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }