mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
2 Commits
35e61e1d98
...
claude/scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc390fcb7b | ||
|
|
735ea9039d |
114
.claude/skills/feature-screenshots/SKILL.md
Normal file
114
.claude/skills/feature-screenshots/SKILL.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user