Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- uses: gradle/actions/setup-gradle@v4

- name: Build + unit tests + quality gate
run: ./gradlew assembleDebug testDebugUnitTest detekt lintDebug --continue --stacktrace --no-daemon
run: ./gradlew assembleDebug testDebugUnitTest verifyRoborazziDebug detekt lintDebug --continue --stacktrace --no-daemon

- name: Upload reports
if: always()
Expand All @@ -49,6 +49,7 @@ jobs:
app/build/reports/detekt/detekt.sarif
app/build/reports/tests/testDebugUnitTest/
app/build/test-results/testDebugUnitTest/*.xml
app/build/outputs/roborazzi/
if-no-files-found: ignore
retention-days: 14

Expand Down
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.detekt)
alias(libs.plugins.roborazzi)
id("nyasa.localization-codegen")
}
android {
Expand Down Expand Up @@ -120,6 +121,11 @@ dependencies {
testImplementation(libs.turbine)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
testImplementation(platform(libs.compose.bom))
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.rule)
testImplementation(libs.compose.test.junit)
testImplementation(libs.androidx.test.core)
testImplementation(libs.room.testing)
androidTestImplementation(libs.androidx.test.junit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ fun StatusScreen(nav: NavHostController) {
}

@Composable
private fun StatusContent(
internal fun StatusContent(
state: StatusUiState,
recentItems: List<RecentItem>,
revealedIds: Set<Long>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.nyasa.notifybridge.screenshot

import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.nyasa.notifybridge.localization.AssetJsonLanguage
import com.nyasa.notifybridge.localization.LocalLanguage
import com.nyasa.notifybridge.ui.theme.NotifyBridgeTheme
import org.junit.Before
import org.junit.Rule
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.util.Locale
import java.util.TimeZone

/**
* Base for Roborazzi (JVM/Robolectric) screenshot tests.
*
* Determinism is pinned here, not per-test:
* - locale + timezone are fixed so number/time formatting is stable
* (StatusScreen formats via the default Locale/TimeZone);
* - a real English [AssetJsonLanguage] is provided so composables render
* actual copy — the default [LocalLanguage] is `KeyEchoLanguage`, which
* would render dictionary keys instead of text.
*
* App icons load via PackageManager, which resolves to null under Robolectric
* (no packages installed), so the icon fallback renders deterministically —
* no injection needed.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = RobolectricDeviceQualifiers.Pixel5)
abstract class ScreenshotTest {

@get:Rule
val compose = createComposeRule()

@Before
fun pinEnvironment() {
Locale.setDefault(Locale.US)
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Dublin"))
}

protected fun setScreen(body: @Composable () -> Unit) {
val ctx = ApplicationProvider.getApplicationContext<Context>()
val english = AssetJsonLanguage(tag = "en", context = ctx)
compose.setContent {
CompositionLocalProvider(LocalLanguage provides english) {
NotifyBridgeTheme { body() }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.nyasa.notifybridge.screenshot

import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.nyasa.notifybridge.domain.model.AppLockPrefs
import com.nyasa.notifybridge.domain.model.BrokerConfig
import com.nyasa.notifybridge.domain.model.ConnectionState
import com.nyasa.notifybridge.domain.model.RecentItem
import com.nyasa.notifybridge.ui.status.StatusContent
import com.nyasa.notifybridge.ui.status.StatusUiState
import org.junit.Test

/**
* Screen-level baselines for Status — one per [ConnectionState] chip plus the
* redacted-recent variant. Fixtures mirror the existing @Preview data so
* preview and screenshot stay in sync.
*/
class StatusScreenScreenshotTest : ScreenshotTest() {

private val recents = listOf(
RecentItem(
1L,
"org.thoughtcrime.securesms",
"Signal",
"Alice",
"Are we still on for 6?",
1_716_000_000_000L,
),
RecentItem(
2L,
"com.google.android.gm",
"Gmail",
"Invoice #1042",
"Your receipt is attached",
1_716_000_300_000L,
),
RecentItem(
3L,
"com.Slack",
"Slack",
"#deploys",
"build green on main",
1_716_000_600_000L,
),
)

private fun state(cs: ConnectionState, depth: Int = 0, redact: Boolean = false) =
StatusUiState(
connectionState = cs,
outboxDepth = depth,
brokerConfig = BrokerConfig(host = "192.168.1.10"),
allowListSize = 3,
appLock = AppLockPrefs(redactBody = redact),
)

private fun shoot(name: String, s: StatusUiState, items: List<RecentItem>) {
setScreen {
StatusContent(
state = s,
recentItems = items,
revealedIds = emptySet(),
onRevealRequest = {},
onNavApps = {},
onNavBroker = {},
onNavPermissions = {},
)
}
compose.onRoot().captureRoboImage("src/test/screenshots/StatusScreen_$name.png")
}

@Test
fun connected() = shoot("connected", state(ConnectionState.CONNECTED, depth = 4), recents)

@Test
fun connecting() = shoot("connecting", state(ConnectionState.CONNECTING, depth = 2), recents)

@Test
fun error() = shoot("error", state(ConnectionState.ERROR), recents)

@Test
fun disconnected() = shoot("disconnected", state(ConnectionState.DISCONNECTED), emptyList())

@Test
fun redacted() = shoot("redacted", state(ConnectionState.CONNECTED, depth = 1, redact = true), recents)
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/src/test/screenshots/StatusScreen_error.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.detekt) apply false
alias(libs.plugins.roborazzi) apply false
}

tasks.register("qualityCheck") {
Expand Down
2 changes: 1 addition & 1 deletion config/detekt/baseline-debug.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
<ID>LongMethod:PermissionsScreen.kt$@Composable private fun PermissionsBottomNav( onStatus: () -&gt; Unit, onApps: () -&gt; Unit, onBroker: () -&gt; Unit, onAccess: () -&gt; Unit, )</ID>
<ID>LongMethod:PermissionsScreen.kt$@Composable private fun PermissionsContent( notifGranted: Boolean, batteryExempt: Boolean, appLock: AppLockPrefs, onOpenNotifSettings: () -&gt; Unit, onRequestBatteryExemption: () -&gt; Unit, onLockEnabledChange: (Boolean) -&gt; Unit, onIdleTimeoutChange: (Long) -&gt; Unit, onRedactBodyChange: (Boolean) -&gt; Unit, onNavStatus: () -&gt; Unit, onNavApps: () -&gt; Unit, onNavBroker: () -&gt; Unit, )</ID>
<ID>LongMethod:StatusScreen.kt$@Composable private fun StatusBottomNav( onStatus: () -&gt; Unit, onApps: () -&gt; Unit, onBroker: () -&gt; Unit, onAccess: () -&gt; Unit, )</ID>
<ID>LongMethod:StatusScreen.kt$@Composable private fun StatusContent( state: StatusUiState, recentItems: List&lt;RecentItem&gt;, revealedIds: Set&lt;Long&gt;, onRevealRequest: (RecentItem) -&gt; Unit, onNavApps: () -&gt; Unit, onNavBroker: () -&gt; Unit, onNavPermissions: () -&gt; Unit, )</ID>
<ID>LongMethod:StatusScreen.kt$@Composable internal fun StatusContent( state: StatusUiState, recentItems: List&lt;RecentItem&gt;, revealedIds: Set&lt;Long&gt;, onRevealRequest: (RecentItem) -&gt; Unit, onNavApps: () -&gt; Unit, onNavBroker: () -&gt; Unit, onNavPermissions: () -&gt; Unit, )</ID>
<ID>LongParameterList:AppsScreen.kt$( rows: List&lt;AppRow&gt;, query: String, icons: Map&lt;String, android.graphics.drawable.Drawable?&gt;, onQueryChange: (String) -&gt; Unit, onToggle: (String, Boolean) -&gt; Unit, onNavStatus: () -&gt; Unit, onNavBroker: () -&gt; Unit, onNavPermissions: () -&gt; Unit, )</ID>
<ID>LongParameterList:BrokerScreen.kt$( config: BrokerConfig, testResult: String?, saving: Boolean, onHostChange: (String) -&gt; Unit, onPortChange: (String) -&gt; Unit, onDeviceNameChange: (String) -&gt; Unit, onUsernameChange: (String) -&gt; Unit, onPasswordChange: (String) -&gt; Unit, onTlsModeChange: (TlsMode) -&gt; Unit, onPickCertFile: () -&gt; Unit, onTest: () -&gt; Unit, onSave: () -&gt; Unit, onBack: () -&gt; Unit, onNavStatus: () -&gt; Unit, onNavApps: () -&gt; Unit, onNavPermissions: () -&gt; Unit, )</ID>
<ID>LongParameterList:BrokerScreen.kt$( label: String, value: String, onValueChange: (String) -&gt; Unit, placeholder: String = "", supportingText: String? = null, keyboardType: KeyboardType = KeyboardType.Text, leadingIcon: (@Composable () -&gt; Unit)? = null, )</ID>
Expand Down
2 changes: 1 addition & 1 deletion config/detekt/baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
<ID>LongMethod:PermissionsScreen.kt$@Composable private fun PermissionsBottomNav( onStatus: () -&gt; Unit, onApps: () -&gt; Unit, onBroker: () -&gt; Unit, onAccess: () -&gt; Unit, )</ID>
<ID>LongMethod:PermissionsScreen.kt$@Composable private fun PermissionsContent( notifGranted: Boolean, batteryExempt: Boolean, appLock: AppLockPrefs, onOpenNotifSettings: () -&gt; Unit, onRequestBatteryExemption: () -&gt; Unit, onLockEnabledChange: (Boolean) -&gt; Unit, onIdleTimeoutChange: (Long) -&gt; Unit, onRedactBodyChange: (Boolean) -&gt; Unit, onNavStatus: () -&gt; Unit, onNavApps: () -&gt; Unit, onNavBroker: () -&gt; Unit, onNavLanguage: () -&gt; Unit, )</ID>
<ID>LongMethod:StatusScreen.kt$@Composable private fun StatusBottomNav( onStatus: () -&gt; Unit, onApps: () -&gt; Unit, onBroker: () -&gt; Unit, onAccess: () -&gt; Unit, )</ID>
<ID>LongMethod:StatusScreen.kt$@Composable private fun StatusContent( state: StatusUiState, recentItems: List&lt;RecentItem&gt;, revealedIds: Set&lt;Long&gt;, onRevealRequest: (RecentItem) -&gt; Unit, onNavApps: () -&gt; Unit, onNavBroker: () -&gt; Unit, onNavPermissions: () -&gt; Unit, )</ID>
<ID>LongMethod:StatusScreen.kt$@Composable internal fun StatusContent( state: StatusUiState, recentItems: List&lt;RecentItem&gt;, revealedIds: Set&lt;Long&gt;, onRevealRequest: (RecentItem) -&gt; Unit, onNavApps: () -&gt; Unit, onNavBroker: () -&gt; Unit, onNavPermissions: () -&gt; Unit, )</ID>
<ID>LongParameterList:AppsScreen.kt$( rows: List&lt;AppRow&gt;, query: String, icons: Map&lt;String, android.graphics.drawable.Drawable?&gt;, onQueryChange: (String) -&gt; Unit, onToggle: (String, Boolean) -&gt; Unit, onNavStatus: () -&gt; Unit, onNavBroker: () -&gt; Unit, onNavPermissions: () -&gt; Unit, )</ID>
<ID>LongParameterList:BrokerScreen.kt$( config: BrokerConfig, testResult: String?, saving: Boolean, onHostChange: (String) -&gt; Unit, onPortChange: (String) -&gt; Unit, onDeviceNameChange: (String) -&gt; Unit, onUsernameChange: (String) -&gt; Unit, onPasswordChange: (String) -&gt; Unit, onTlsModeChange: (TlsMode) -&gt; Unit, onPickCertFile: () -&gt; Unit, onTest: () -&gt; Unit, onSave: () -&gt; Unit, onBack: () -&gt; Unit, onNavStatus: () -&gt; Unit, onNavApps: () -&gt; Unit, onNavPermissions: () -&gt; Unit, )</ID>
<ID>LongParameterList:BrokerScreen.kt$( label: String, value: String, onValueChange: (String) -&gt; Unit, placeholder: String = "", supportingText: String? = null, keyboardType: KeyboardType = KeyboardType.Text, leadingIcon: (@Composable () -&gt; Unit)? = null, )</ID>
Expand Down
Loading
Loading