From d8c801072a7b0b4dd19506c67e078c78751ef23f Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Sat, 5 Sep 2026 22:29:25 +0300 Subject: [PATCH] Replace emulator UI tests with JVM coverage --- .github/workflows/ci.yml | 123 ------------------ AGENTS.md | 30 +++-- app/build.gradle.kts | 6 - .../net/megaproxy487/AppNavigationTest.kt | 111 ---------------- .../java/net/megaproxy487/AppNavigation.kt | 39 ++++++ .../java/net/megaproxy487/SettingsScreens.kt | 22 +++- .../megaproxy487/TrafficUnitPreferences.kt | 9 +- .../megaproxy487/AppNavigationContractTest.kt | 22 ++++ .../megaproxy487/LocalizedResourcesTest.kt | 45 +++++++ .../TrafficUnitPreferencesTest.kt | 20 +++ docs/en/fastlane.md | 4 - docs/ru/fastlane.md | 4 - fastlane/Fastfile | 34 ----- 13 files changed, 164 insertions(+), 305 deletions(-) delete mode 100644 app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt create mode 100644 app/src/test/java/net/megaproxy487/AppNavigationContractTest.kt create mode 100644 app/src/test/java/net/megaproxy487/LocalizedResourcesTest.kt create mode 100644 app/src/test/java/net/megaproxy487/TrafficUnitPreferencesTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60b0740..a6564c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,126 +117,3 @@ jobs: echo echo "Artifacts expire after 14 days. Neither APK uses the MegaProxy release key." } >> "$GITHUB_STEP_SUMMARY" - - android-ui-tests: - name: Android UI tests - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Enable KVM device access - run: | - if [[ ! -e /dev/kvm ]]; then - echo "::error::KVM is unavailable on this runner" - exit 1 - fi - - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - if [[ ! -r /dev/kvm || ! -w /dev/kvm ]]; then - echo "::error::The runner cannot access /dev/kvm" - ls -l /dev/kvm - exit 1 - fi - echo "KVM is available; the emulator will use hardware acceleration" - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: "21" - cache: gradle - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: native/go.mod - cache-dependency-path: native/go.sum - - - name: Set up Ruby and Fastlane - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.4.10" - bundler-cache: true - - - name: Install native build dependencies - run: | - sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" - yes | "$sdkmanager" --licenses >/dev/null || true - "$sdkmanager" "ndk;29.0.14206865" - echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" - - - name: Run Compose UI tests on an emulator - uses: reactivecircus/android-emulator-runner@v2.38.0 - with: - api-level: 30 - target: aosp_atd - arch: x86_64 - disable-linux-hw-accel: false - disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: bundle exec fastlane android ui_tests - - - name: Verify UI tests actually ran - run: | - shopt -s nullglob - reports=(app/build/outputs/androidTest-results/connected/debug/TEST-*.xml) - if (( ${#reports[@]} == 0 )); then - echo "No Android UI test XML reports were produced" >&2 - exit 1 - fi - - tests=0 - failures=0 - errors=0 - skipped=0 - for report in "${reports[@]}"; do - suite="$(sed -nE 's/.*tests="([0-9]+)" failures="([0-9]+)" errors="([0-9]+)" skipped="([0-9]+)".*/\1 \2 \3 \4/p' "$report" | head -n 1)" - if [[ -z "$suite" ]]; then - echo "Cannot read test counters from $report" >&2 - exit 1 - fi - read -r suite_tests suite_failures suite_errors suite_skipped <<< "$suite" - (( tests += suite_tests )) || true - (( failures += suite_failures )) || true - (( errors += suite_errors )) || true - (( skipped += suite_skipped )) || true - done - - if (( tests < 4 )); then - echo "Expected at least 4 Android UI tests, found $tests" >&2 - exit 1 - fi - if (( failures != 0 || errors != 0 || skipped != 0 )); then - echo "Android UI test results: tests=$tests failures=$failures errors=$errors skipped=$skipped" >&2 - exit 1 - fi - expected_tests=( - everySettingsDestinationOpensAndReturnsThroughTheNavHost - russianLocaleIsAppliedAcrossNavigation - settingsAndProfilesShareTheMainActivityBackStack - trafficUnitsDefaultToIecAndPersistSiSelection - ) - for expected_test in "${expected_tests[@]}"; do - if ! grep -Fq "name=\"$expected_test\"" "${reports[@]}"; then - echo "Expected Android UI test did not run: $expected_test" >&2 - exit 1 - fi - done - echo "Verified Android UI test results: tests=$tests failures=0 errors=0 skipped=0" - - - name: Publish UI test report - if: ${{ always() && hashFiles('app/build/outputs/androidTest-results/connected/debug/TEST-*.xml') != '' }} - uses: actions/upload-artifact@v7 - with: - name: android-ui-test-report - path: | - app/build/reports/androidTests/connected/debug - app/build/outputs/androidTest-results/connected/debug - if-no-files-found: error - retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index 9f3d204..c2afbdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,18 +18,19 @@ branch names, credentials, signing material, or other secrets. ## CI and artifacts -- Pull requests must run native tests, Android unit/lint/build checks, and instrumented Compose UI - tests. +- Pull requests must run native tests and Android JVM unit/lint/build checks. Do not require an + Android emulator in GitHub Actions: hosted-runner KVM availability proved too unreliable for a + trustworthy required check. - PR builds may publish debug and unsigned APK artifacts. They must never have access to release signing material and must never produce or publish a signed release APK. - Surface downloadable APK artifacts in the GitHub Actions job summary in addition to uploading them through `actions/upload-artifact`. -- UI tests use the lightweight API 30 `aosp_atd` x86_64 image. KVM acceleration is required: fail - quickly with a clear diagnostic when `/dev/kvm` is unavailable instead of falling back to the - unstable software emulator. An infrastructure retry should use a fresh job/runner rather than - repeatedly invoking instrumentation in the same crashed emulator. -- Treat `Starting 0 tests`, missing XML reports, or fewer than the expected tests as a CI failure; - a green Gradle process alone is not proof that instrumentation tests ran. +- Prefer extracting UI-facing decisions into small production contracts and testing those with + deterministic JVM unit tests. Resource parity, navigation destination wiring, preference + serialization/defaults, formatting, and state transitions should not require a device. +- Keep device-only tests out of required GitHub CI unless the project later adopts a dependable + device farm or controlled self-hosted runner. Do not reintroduce a software-emulated Android + fallback. ## Releases and distribution @@ -52,21 +53,22 @@ branch names, credentials, signing material, or other secrets. - In Russian UI, translate “samples” as “сэмплы”, not “попытки”. Relative latency timestamps belong on a separate line in smaller text. - Connection diagnostics include exit IP and country, with fallback providers so one unavailable - external service does not make the whole check fail. UI tests should assert that country is - actually presented, not merely fetched internally. + external service does not make the whole check fail. Keep presentation inputs and formatting + covered by JVM tests. ## Architecture landmarks - `ProxyVpnService` extends Android's standard `android.net.VpnService`. It owns VPN lifecycle, creates the TUN interface, coordinates profiles/reconnects/status, and hands the TUN file descriptor to the native networking layer. Native code performs the actual proxy forwarding. -- Navigation uses a single activity/back stack. Navigation, localization, settings persistence, - and cross-screen behavior should be covered by Compose instrumentation tests. +- Navigation uses a single activity/back stack. Keep route and settings-destination definitions in + shared production contracts whose completeness and uniqueness can be checked by JVM tests. ## Collaboration workflow - Put each new change on a branch based on the current `main` and normally deliver it as one focused GitHub pull request. After a PR is merged, start subsequent work from the updated `main` instead of continuing on the merged branch. -- Before changing CI after a failure, inspect the full job log and distinguish application test - failures from runner/emulator infrastructure failures. +- Keep required CI deterministic. If a check depends on unreliable hosted-runner capabilities, + replace it with JVM coverage where practical or move it to purpose-built infrastructure rather + than normalizing repeated reruns. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 631a011..805c27e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,7 +48,6 @@ android { versionCode = versionCodeBase * 1000 + versionVariantCode versionName = "0.0.12" buildConfigField("String", "GIT_COMMIT_HASH", "\"$gitCommitHash\"") - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" if (versionVariant != "universal") { // Keep every ABI-specific APK genuinely single-ABI. Without this, // transitive native libraries are packaged for every architecture @@ -119,11 +118,6 @@ dependencies { implementation("androidx.navigation:navigation-compose:2.9.5") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250107") - androidTestImplementation(platform("androidx.compose:compose-bom:2025.01.01")) - androidTestImplementation("androidx.compose.ui:ui-test-junit4") - androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - debugImplementation("androidx.compose.ui:ui-test-manifest") runtimeOnly(files("libs/megaproxy.aar")) } diff --git a/app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt b/app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt deleted file mode 100644 index 75ec6e7..0000000 --- a/app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt +++ /dev/null @@ -1,111 +0,0 @@ -package net.megaproxy487 - -import android.content.Context -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createEmptyComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class AppNavigationTest { - @get:Rule - val compose = createEmptyComposeRule() - - private lateinit var scenario: ActivityScenario - - @Before - fun launchApp() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - context.getSharedPreferences("app_language", Context.MODE_PRIVATE) - .edit().putString("language", "en").commit() - context.getSharedPreferences("display_preferences", Context.MODE_PRIVATE) - .edit().clear().commit() - context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE) - .edit().putLong("last_request_at", System.currentTimeMillis()).commit() - scenario = ActivityScenario.launch(MainActivity::class.java) - } - - @After - fun closeApp() { - scenario.close() - } - - @Test - fun settingsAndProfilesShareTheMainActivityBackStack() { - compose.onNodeWithTag("screen-main").assertIsDisplayed() - - compose.onNodeWithText("Settings").performClick() - compose.onNodeWithTag("screen-settings").assertIsDisplayed() - - compose.onNodeWithText("Profiles").performClick() - compose.onNodeWithTag("screen-profiles").assertIsDisplayed() - - compose.onNodeWithContentDescription("Back").performClick() - compose.onNodeWithTag("screen-settings").assertIsDisplayed() - - compose.onNodeWithContentDescription("Back").performClick() - compose.onNodeWithTag("screen-main").assertIsDisplayed() - } - - @Test - fun everySettingsDestinationOpensAndReturnsThroughTheNavHost() { - compose.onNodeWithText("Settings").performClick() - - listOf( - "Profiles" to "profiles", - "Always-on VPN" to "always-on", - "Fingerprints" to "fingerprints", - "Split tunneling" to "split-tunnel", - "Failover" to "failover", - "Visibility" to "visibility", - "Diagnostic log" to "diagnostic-log", - ).forEach { (label, route) -> - compose.onNodeWithText(label).performScrollTo().performClick() - compose.onNodeWithTag("screen-$route").assertIsDisplayed() - compose.onNodeWithContentDescription("Back").performClick() - compose.onNodeWithTag("screen-settings").assertIsDisplayed() - } - } - - @Test - fun russianLocaleIsAppliedAcrossNavigation() { - scenario.close() - InstrumentationRegistry.getInstrumentation().targetContext - .getSharedPreferences("app_language", Context.MODE_PRIVATE) - .edit().putString("language", "ru").commit() - scenario = ActivityScenario.launch(MainActivity::class.java) - - compose.onNodeWithText("Настройки").performClick() - compose.onNodeWithText("Профили").assertIsDisplayed() - compose.onNodeWithText("Диагностический журнал").performScrollTo().assertIsDisplayed() - } - - @Test - fun trafficUnitsDefaultToIecAndPersistSiSelection() { - compose.onNodeWithText("Settings").performClick() - compose.onNodeWithText("Traffic units").performScrollTo().performClick() - compose.onNodeWithText("IEC binary (KiB, MiB, GiB · 1024)").assertIsDisplayed() - compose.onNodeWithText("SI decimal (KB, MB, GB · 1000)").performClick() - compose.onNodeWithText( - "Units used for speed and total traffic · SI decimal (KB, MB, GB · 1000)", - ).assertIsDisplayed() - - scenario.recreate() - - compose.onNodeWithTag("screen-settings").assertIsDisplayed() - compose.onNodeWithText( - "Units used for speed and total traffic · SI decimal (KB, MB, GB · 1000)", - ).performScrollTo().assertIsDisplayed() - } -} diff --git a/app/src/main/java/net/megaproxy487/AppNavigation.kt b/app/src/main/java/net/megaproxy487/AppNavigation.kt index e80ce03..2fce64e 100644 --- a/app/src/main/java/net/megaproxy487/AppNavigation.kt +++ b/app/src/main/java/net/megaproxy487/AppNavigation.kt @@ -29,9 +29,48 @@ internal object AppRoute { const val DIAGNOSTIC_LOG = "diagnostic-log" const val SSH_HOST_KEY = "ssh-host-key" + val registeredStaticRoutes = setOf( + MAIN, + SETTINGS, + PROFILES, + ALWAYS_ON, + FINGERPRINTS, + SPLIT_TUNNEL, + FAILOVER, + VISIBILITY, + CONNECTION_TEST, + DIAGNOSTIC_LOG, + SSH_HOST_KEY, + ) + fun profileEditor(profileId: String) = "profile/${Uri.encode(profileId)}" } +internal data class SettingsDestination( + val titleRes: Int, + val descriptionRes: Int, + val route: String, +) + +internal val connectionSettingsDestinations = listOf( + SettingsDestination(R.string.profiles, R.string.profiles_description, AppRoute.PROFILES), + SettingsDestination(R.string.always_on_vpn, R.string.always_on_description, AppRoute.ALWAYS_ON), + SettingsDestination(R.string.fingerprints, R.string.fingerprints_description, AppRoute.FINGERPRINTS), + SettingsDestination(R.string.split_tunneling, R.string.split_tunneling_description, AppRoute.SPLIT_TUNNEL), + SettingsDestination(R.string.failover, R.string.failover_description, AppRoute.FAILOVER), +) + +internal val visibilitySettingsDestination = + SettingsDestination(R.string.visibility, R.string.visibility_description, AppRoute.VISIBILITY) + +internal val diagnosticLogSettingsDestination = + SettingsDestination(R.string.diagnostic_log, R.string.diagnostic_log_description, AppRoute.DIAGNOSTIC_LOG) + +internal val settingsDestinations = connectionSettingsDestinations + listOf( + visibilitySettingsDestination, + diagnosticLogSettingsDestination, +) + @Composable internal fun MegaProxyNavHost(activity: Activity) { val navController = rememberNavController() diff --git a/app/src/main/java/net/megaproxy487/SettingsScreens.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt index 986f8a1..4d8b723 100644 --- a/app/src/main/java/net/megaproxy487/SettingsScreens.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -104,13 +104,17 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga SettingsScaffold(onBack, stringResource(R.string.settings)) { Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleMedium) - SettingsButton(stringResource(R.string.profiles), stringResource(R.string.profiles_description)) { onNavigate(AppRoute.PROFILES) } - SettingsButton(stringResource(R.string.always_on_vpn), stringResource(R.string.always_on_description)) { onNavigate(AppRoute.ALWAYS_ON) } - SettingsButton(stringResource(R.string.fingerprints), stringResource(R.string.fingerprints_description)) { onNavigate(AppRoute.FINGERPRINTS) } - SettingsButton(stringResource(R.string.split_tunneling), stringResource(R.string.split_tunneling_description)) { onNavigate(AppRoute.SPLIT_TUNNEL) } - SettingsButton(stringResource(R.string.failover), stringResource(R.string.failover_description)) { onNavigate(AppRoute.FAILOVER) } + connectionSettingsDestinations.forEach { destination -> + SettingsButton(stringResource(destination.titleRes), stringResource(destination.descriptionRes)) { + onNavigate(destination.route) + } + } Text(stringResource(R.string.diagnostics), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) - SettingsButton(stringResource(R.string.visibility), stringResource(R.string.visibility_description)) { onNavigate(AppRoute.VISIBILITY) } + visibilitySettingsDestination.let { destination -> + SettingsButton(stringResource(destination.titleRes), stringResource(destination.descriptionRes)) { + onNavigate(destination.route) + } + } if (!batteryOptimizationDisabled) { SettingsButton(stringResource(R.string.battery_settings), stringResource(R.string.battery_settings_description)) { activity.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { @@ -118,7 +122,11 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga }) } } - SettingsButton(stringResource(R.string.diagnostic_log), stringResource(R.string.diagnostic_log_description)) { onNavigate(AppRoute.DIAGNOSTIC_LOG) } + diagnosticLogSettingsDestination.let { destination -> + SettingsButton(stringResource(destination.titleRes), stringResource(destination.descriptionRes)) { + onNavigate(destination.route) + } + } Text(stringResource(R.string.appearance_and_language), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) SettingsButton( stringResource(R.string.language), diff --git a/app/src/main/java/net/megaproxy487/TrafficUnitPreferences.kt b/app/src/main/java/net/megaproxy487/TrafficUnitPreferences.kt index e368250..783da9e 100644 --- a/app/src/main/java/net/megaproxy487/TrafficUnitPreferences.kt +++ b/app/src/main/java/net/megaproxy487/TrafficUnitPreferences.kt @@ -9,13 +9,18 @@ internal object TrafficUnitPreferences { fun current(context: Context): TrafficUnitSystem { val stored = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) .getString(KEY_TRAFFIC_UNITS, null) - return TrafficUnitSystem.entries.firstOrNull { it.name == stored } ?: TrafficUnitSystem.IEC + return fromStoredValue(stored) } fun set(context: Context, unitSystem: TrafficUnitSystem) { context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) .edit() - .putString(KEY_TRAFFIC_UNITS, unitSystem.name) + .putString(KEY_TRAFFIC_UNITS, toStoredValue(unitSystem)) .apply() } + + internal fun fromStoredValue(stored: String?): TrafficUnitSystem = + TrafficUnitSystem.entries.firstOrNull { it.name == stored } ?: TrafficUnitSystem.IEC + + internal fun toStoredValue(unitSystem: TrafficUnitSystem): String = unitSystem.name } diff --git a/app/src/test/java/net/megaproxy487/AppNavigationContractTest.kt b/app/src/test/java/net/megaproxy487/AppNavigationContractTest.kt new file mode 100644 index 0000000..a5ec5c4 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/AppNavigationContractTest.kt @@ -0,0 +1,22 @@ +package net.megaproxy487 + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppNavigationContractTest { + @Test + fun everySettingsDestinationHasARegisteredRoute() { + assertEquals(7, settingsDestinations.size) + assertEquals(settingsDestinations.size, settingsDestinations.map { it.route }.distinct().size) + assertTrue(settingsDestinations.all { it.route in AppRoute.registeredStaticRoutes }) + } + + @Test + fun mainSettingsAndProfilesAreSeparateDestinations() { + assertTrue(AppRoute.MAIN in AppRoute.registeredStaticRoutes) + assertTrue(AppRoute.SETTINGS in AppRoute.registeredStaticRoutes) + assertTrue(AppRoute.PROFILES in AppRoute.registeredStaticRoutes) + assertEquals(3, setOf(AppRoute.MAIN, AppRoute.SETTINGS, AppRoute.PROFILES).size) + } +} diff --git a/app/src/test/java/net/megaproxy487/LocalizedResourcesTest.kt b/app/src/test/java/net/megaproxy487/LocalizedResourcesTest.kt new file mode 100644 index 0000000..1230231 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/LocalizedResourcesTest.kt @@ -0,0 +1,45 @@ +package net.megaproxy487 + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocalizedResourcesTest { + @Test + fun russianResourcesCoverEveryEnglishString() { + val english = stringsIn(resourceFile("values/strings.xml")) + val russian = stringsIn(resourceFile("values-ru/strings.xml")) + + assertTrue("Missing Russian strings: ${english.keys - russian.keys}", russian.keys.containsAll(english.keys)) + } + + @Test + fun navigationAndTrafficLabelsHaveExpectedRussianTranslations() { + val russian = stringsIn(resourceFile("values-ru/strings.xml")) + + assertEquals("Настройки", russian["settings"]) + assertEquals("Профили", russian["profiles"]) + assertEquals("Диагностический журнал", russian["diagnostic_log"]) + assertTrue(russian.getValue("traffic_units_iec").contains("KiB, MiB, GiB")) + assertTrue(russian.getValue("traffic_units_si").contains("KB, MB, GB")) + } + + private fun resourceFile(relativePath: String): File = + sequenceOf(File("src/main/res", relativePath), File("app/src/main/res", relativePath)) + .firstOrNull(File::isFile) + ?: error("Cannot find Android resource file: $relativePath") + + private fun stringsIn(file: File): Map { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + val nodes = document.getElementsByTagName("string") + return buildMap { + for (index in 0 until nodes.length) { + val element = nodes.item(index) + if (element.attributes.getNamedItem("translatable")?.nodeValue == "false") continue + put(element.attributes.getNamedItem("name").nodeValue, element.textContent.trim()) + } + } + } +} diff --git a/app/src/test/java/net/megaproxy487/TrafficUnitPreferencesTest.kt b/app/src/test/java/net/megaproxy487/TrafficUnitPreferencesTest.kt new file mode 100644 index 0000000..4a4d422 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/TrafficUnitPreferencesTest.kt @@ -0,0 +1,20 @@ +package net.megaproxy487 + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TrafficUnitPreferencesTest { + @Test + fun iecIsTheDefaultForMissingOrInvalidPreferences() { + assertEquals(TrafficUnitSystem.IEC, TrafficUnitPreferences.fromStoredValue(null)) + assertEquals(TrafficUnitSystem.IEC, TrafficUnitPreferences.fromStoredValue("unknown")) + } + + @Test + fun everyUnitSystemRoundTripsThroughItsStoredValue() { + TrafficUnitSystem.entries.forEach { unitSystem -> + val stored = TrafficUnitPreferences.toStoredValue(unitSystem) + assertEquals(unitSystem, TrafficUnitPreferences.fromStoredValue(stored)) + } + } +} diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 9cb9785..8eb1645 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -37,14 +37,10 @@ That command lists the lanes available in the checked-out version of the project | --- | --- | | `bundle exec fastlane android native_tests` | Runs all Go tests with the race detector. | | `bundle exec fastlane android android_checks` | Builds the native AAR, runs Android unit tests and lint, builds a debug APK, then builds and verifies an unsigned release APK. It rejects any release-signing environment variables. | -| `bundle exec fastlane android ui_tests` | Builds the native AAR and runs the instrumented Compose UI tests on a connected Android device or emulator. This lane runs in pull-request CI. | | `bundle exec fastlane android test` | Runs `native_tests` and `android_checks`; this is the normal pre-commit command. | | `bundle exec fastlane android debug_artifact` | Builds `app/build/outputs/apk/debug/app-debug.apk`. | | `bundle exec fastlane android release_artifacts` | Builds and verifies the signed release APKs, AAB, native debug symbols, and `SHA256SUMS` in `dist/release`. | -`ui_tests` requires a running Android device or emulator visible to `adb`. The other lanes do not -start an emulator automatically. - The release lane requires the signing configuration described in [Signed release builds](../../README.md#signed-release-builds). It builds artifacts but does not upload them to Google Play or publish a GitHub Release. GitHub Actions invokes the same lane and diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 81741fa..14ca945 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -38,14 +38,10 @@ bundle exec fastlane lanes | --- | --- | | `bundle exec fastlane android native_tests` | Запускает все Go-тесты с race detector. | | `bundle exec fastlane android android_checks` | Собирает native AAR, запускает Android unit-тесты и lint, собирает debug APK, затем собирает и проверяет unsigned release APK. Команда отклоняет переменные release-подписи. | -| `bundle exec fastlane android ui_tests` | Собирает native AAR и запускает инструментальные Compose UI-тесты на подключённом Android-устройстве или эмуляторе. Этот lane выполняется в CI каждого pull request. | | `bundle exec fastlane android test` | Выполняет `native_tests` и `android_checks`; основная команда перед коммитом. | | `bundle exec fastlane android debug_artifact` | Собирает `app/build/outputs/apk/debug/app-debug.apk`. | | `bundle exec fastlane android release_artifacts` | Собирает и проверяет подписанные APK, AAB, native debug symbols и `SHA256SUMS` в `dist/release`. | -Для `ui_tests` нужен запущенный Android-девайс или эмулятор, видимый через `adb`. Остальные lane -не запускают эмулятор автоматически. - Для release lane нужна конфигурация подписи из раздела [Signed release builds](../../README.md#signed-release-builds). Lane только собирает артефакты: он не загружает их в Google Play и не публикует GitHub Release. GitHub Actions запускает тот же lane, diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 74759ba..04e016a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -64,40 +64,6 @@ platform :android do UI.success("Verified unsigned release APK: app/build/outputs/apk/release/app-release-unsigned.apk") end - desc "Run instrumented Compose UI tests on a connected Android device or emulator" - lane :ui_tests do - native_library - report_pattern = File.join( - project_root, - "app/build/outputs/androidTest-results/connected/debug/TEST-*.xml" - ) - Dir.glob(report_pattern).each { |report| File.delete(report) } - attempts = 0 - begin - attempts += 1 - gradle( - task: "connectedDebugAndroidTest", - flags: "--no-daemon", - project_dir: project_root - ) - rescue - reports = Dir.glob(report_pattern) - executed_tests = reports.sum do |report| - (File.read(report).match(/tests="(\d+)"/)&.captures&.first || "0").to_i - end - - # A software-emulated Android process can occasionally die before the - # instrumentation runner starts. Retry that infrastructure failure once, - # but never hide an assertion failure from a test that actually ran. - raise if attempts >= 2 || reports.empty? || executed_tests.positive? - - UI.important("Instrumentation exited before running a test; retrying once") - reports.each { |report| File.delete(report) } - sh("adb wait-for-device") - retry - end - end - desc "Run every native and Android test and check" lane :test do native_tests