diff --git a/.gitea/scripts/check_test_results.py b/.gitea/scripts/check_test_results.py new file mode 100644 index 00000000..b37d0043 --- /dev/null +++ b/.gitea/scripts/check_test_results.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Require the critical runtime regression suites before publishing APKs.""" +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +WORKERS = 'dev.octoshrimpy.quik.worker.InjectionWorkerFactoryTest' +REQUIRED = { + WORKERS: { + 'matrixCoroutineWorkerIsCreatedByTheApplicationFactory', + 'matrixWatcherStartsTheExpectedService', + 'housekeepingReceivesItsRepository', + 'smsWorkerReceivesEveryRequiredDependency', + 'mmsWorkerReceivesEveryRequiredDependency', + 'eachRequestCreatesANewWorkerInstance', + }, + 'ch.skew.remotrix.bridge.BackfillRunnerTest': set(), + 'ch.skew.remotrix.bridge.RoomResolverTest': set(), + 'ch.skew.remotrix.bridge.CloseMatrixClientTest': set(), + 'ch.skew.remotrix.bridge.BridgeResetTest': set(), +} + + +def check(report_dirs): + passed = {} + count = 0 + for directory in report_dirs: + for path in Path(directory).glob('TEST-*.xml'): + suite = ET.parse(path).getroot() + for case in suite.iter('testcase'): + name = case.get('name', '') + classname = case.get('classname', suite.get('name', '')) + if case.find('failure') is not None or case.find('error') is not None: + raise ValueError(f'Failed test: {classname}.{name}') + if case.find('skipped') is not None: + if classname in REQUIRED: + raise ValueError(f'Skipped critical test: {classname}.{name}') + continue + passed.setdefault(classname, set()).add(name) + count += 1 + for classname, names in REQUIRED.items(): + if not passed.get(classname): + raise ValueError(f'Missing critical test suite: {classname}') + missing = names - passed[classname] + if missing: + raise ValueError(f'Missing critical tests in {classname}: {sorted(missing)}') + return count + + +def main(): + try: + total = check([ + ROOT / 'presentation/build/test-results/testDebugUnitTest', + ROOT / 'remotrix-sync/build/test-results/testDebugUnitTest', + ]) + except (OSError, ValueError, ET.ParseError) as error: + print(f'Release test gate failed: {error}', file=sys.stderr) + return 1 + print(f'Release test gate passed: {total} tests, all critical suites present.') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.gitea/tests/test_test_results.py b/.gitea/tests/test_test_results.py new file mode 100644 index 00000000..83e1d2b0 --- /dev/null +++ b/.gitea/tests/test_test_results.py @@ -0,0 +1,54 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest +import xml.etree.ElementTree as ET + +spec = importlib.util.spec_from_file_location( + 'test_results_gate', Path(__file__).parents[1] / 'scripts/check_test_results.py') +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +class ReleaseTestGateTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + for classname, names in gate.REQUIRED.items(): + suite = ET.Element('testsuite', name=classname) + for name in names or {'regressionTest'}: + ET.SubElement(suite, 'testcase', classname=classname, name=name) + ET.ElementTree(suite).write(self.directory / f'TEST-{classname}.xml') + + def change_worker_report(self, change): + path = self.directory / f'TEST-{gate.WORKERS}.xml' + tree = ET.parse(path) + change(tree.getroot()) + tree.write(path) + + def test_all_required_tests_pass(self): + self.assertEqual(gate.check([self.directory]), 10) + + def test_empty_report_directory_blocks_release(self): + with self.assertRaisesRegex(ValueError, 'Missing critical test suite'): + gate.check([self.directory / 'missing']) + + def test_failed_worker_test_blocks_release(self): + self.change_worker_report(lambda r: ET.SubElement(r[0], 'failure')) + with self.assertRaisesRegex(ValueError, 'Failed test'): + gate.check([self.directory]) + + def test_skipped_worker_test_blocks_release(self): + self.change_worker_report(lambda r: ET.SubElement(r[0], 'skipped')) + with self.assertRaisesRegex(ValueError, 'Skipped critical test'): + gate.check([self.directory]) + + def test_removed_coroutine_worker_test_blocks_release(self): + def remove(root): + for case in list(root): + if case.get('name') == 'matrixCoroutineWorkerIsCreatedByTheApplicationFactory': + root.remove(case) + self.change_worker_report(remove) + with self.assertRaisesRegex(ValueError, 'Missing critical tests'): + gate.check([self.directory]) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index daf369b3..260951aa 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -63,8 +63,11 @@ jobs: env: BUILD_NUMBER: ${{ github.run_number }} - - name: Test Matrix bridge - run: ./gradlew :remotrix-sync:testDebugUnitTest + - name: Test worker factory and Matrix bridge + run: | + set -eu + ./gradlew :presentation:cleanTestDebugUnitTest :remotrix-sync:cleanTestDebugUnitTest :presentation:testDebugUnitTest :remotrix-sync:testDebugUnitTest + python3 .gitea/scripts/check_test_results.py - name: Build Debug APK run: | diff --git a/README.md b/README.md index a6e840e4..38e28834 100644 --- a/README.md +++ b/README.md @@ -186,3 +186,23 @@ QUIK is developed and maintained by [Marcos Jones](https://github.com/octoshrimp ## License QUIK is released under the **The GNU General Public License v3.0 (GPLv3)**, which can be found in the [LICENSE](LICENSE) file in the root of this project. + +## Automated release checks + +Before building or publishing APKs, Gitea Actions runs Robolectric tests against the actual +`InjectionWorkerFactory`. They construct every worker type used by Quik and the Matrix bridge, +verify SMS/MMS dependency injection, and exercise the Matrix watcher's service-start intent. +These tests use simulated Android services and mocked repositories, without sending SMS or +connecting to Matrix. Matrix room creation, client shutdown, reset and history import also have +regression tests. Signing checks run separately. + +Run the same runtime gate locally with JDK 17 and the Android SDK: + +```sh +./gradlew :presentation:cleanTestDebugUnitTest :remotrix-sync:cleanTestDebugUnitTest :presentation:testDebugUnitTest :remotrix-sync:testDebugUnitTest +python3 .gitea/scripts/check_test_results.py +``` + +The gate rejects failures, missing required suites, and skipped critical tests. A failed gate +stops the workflow before publication. These tests cover known regression scenarios; they do +not replace testing the installed Release APK on Android devices. diff --git a/presentation/build.gradle b/presentation/build.gradle index 1ca4e42f..34553242 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -193,7 +193,9 @@ dependencies { androidTestImplementation "org.mockito:mockito-android:$mockito_version" testImplementation "androidx.test:runner:$androidx_testrunner_version" testImplementation "junit:junit:$junit_version" - testImplementation "org.mockito:mockito-core:$mockito_version" + testImplementation "org.mockito:mockito-inline:4.11.0" + testImplementation "org.robolectric:robolectric:4.11.1" + testImplementation "androidx.work:work-testing:$androidx_work_version" // moshi implementation "com.squareup.moshi:moshi:$moshi_version" diff --git a/presentation/src/test/java/com/moez/QKSMS/worker/InjectionWorkerFactoryTest.kt b/presentation/src/test/java/com/moez/QKSMS/worker/InjectionWorkerFactoryTest.kt new file mode 100644 index 00000000..0c3ec20a --- /dev/null +++ b/presentation/src/test/java/com/moez/QKSMS/worker/InjectionWorkerFactoryTest.kt @@ -0,0 +1,111 @@ +package dev.octoshrimpy.quik.worker + +import android.app.Application +import android.content.ComponentName +import androidx.work.ListenableWorker +import androidx.work.testing.TestListenableWorkerBuilder +import ch.skew.remotrix.background.CommandService +import ch.skew.remotrix.background.ServiceWatcher +import dev.octoshrimpy.quik.blocking.BlockingClient +import dev.octoshrimpy.quik.interactor.UpdateBadge +import dev.octoshrimpy.quik.manager.ActiveConversationManager +import dev.octoshrimpy.quik.manager.NotificationManager +import dev.octoshrimpy.quik.manager.ShortcutManager +import dev.octoshrimpy.quik.repository.ContactRepository +import dev.octoshrimpy.quik.repository.ConversationRepository +import dev.octoshrimpy.quik.repository.MessageContentFilterRepository +import dev.octoshrimpy.quik.repository.MessageRepository +import dev.octoshrimpy.quik.repository.ScheduledMessageRepository +import dev.octoshrimpy.quik.repository.SyncRepository +import dev.octoshrimpy.quik.util.Preferences +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** Real WorkManager construction and Android service intents; no SMS or Matrix network calls. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE, application = Application::class) +class InjectionWorkerFactoryTest { + private val conversationRepo = mock(ConversationRepository::class.java) + private val blockingClient = mock(BlockingClient::class.java) + private val prefs = mock(Preferences::class.java) + private val messageRepo = mock(MessageRepository::class.java) + private val updateBadge = mock(UpdateBadge::class.java) + private val shortcutManager = mock(ShortcutManager::class.java) + private val scheduledMessages = mock(ScheduledMessageRepository::class.java) + private val notifications = mock(NotificationManager::class.java) + private val activeConversation = mock(ActiveConversationManager::class.java) + private val syncRepo = mock(SyncRepository::class.java) + private val filters = mock(MessageContentFilterRepository::class.java) + private val contacts = mock(ContactRepository::class.java) + private val factory = InjectionWorkerFactory( + conversationRepo, blockingClient, prefs, messageRepo, updateBadge, shortcutManager, + scheduledMessages, notifications, activeConversation, syncRepo, filters, contacts + ) + private val application: Application get() = RuntimeEnvironment.getApplication() + + private inline fun build(): W = + TestListenableWorkerBuilder(application).setWorkerFactory(factory).build() + + @Test + fun matrixCoroutineWorkerIsCreatedByTheApplicationFactory() { + assertEquals(ServiceWatcher::class.java, build().javaClass) + } + + @Test + fun matrixWatcherStartsTheExpectedService() = runBlocking { + val worker = build() + assertEquals(ListenableWorker.Result.success(), worker.doWork()) + val intent = shadowOf(application).nextStartedService + assertNotNull(intent) + assertEquals(CommandService.START_ALL, intent.action) + assertEquals(ComponentName(application, CommandService::class.java), intent.component) + } + + @Test + fun housekeepingReceivesItsRepository() { + assertSame(scheduledMessages, build().scheduledMessageRepository) + } + + @Test + fun smsWorkerReceivesEveryRequiredDependency() { + val worker = build() + assertSame(conversationRepo, worker.conversationRepo) + assertSame(blockingClient, worker.blockingClient) + assertSame(prefs, worker.prefs) + assertSame(messageRepo, worker.messageRepo) + assertSame(notifications, worker.notificationManager) + assertSame(updateBadge, worker.updateBadge) + assertSame(shortcutManager, worker.shortcutManager) + assertSame(filters, worker.filterRepo) + assertSame(contacts, worker.contactsRepo) + } + + @Test + fun mmsWorkerReceivesEveryRequiredDependency() { + val worker = build() + assertSame(activeConversation, worker.activeConversationManager) + assertSame(conversationRepo, worker.conversationRepo) + assertSame(blockingClient, worker.blockingClient) + assertSame(prefs, worker.prefs) + assertSame(syncRepo, worker.syncRepo) + assertSame(messageRepo, worker.messageRepo) + assertSame(notifications, worker.notificationManager) + assertSame(updateBadge, worker.updateBadge) + assertSame(shortcutManager, worker.shortcutManager) + assertSame(filters, worker.filterRepo) + assertSame(contacts, worker.contactsRepo) + } + + @Test + fun eachRequestCreatesANewWorkerInstance() { + assertNotSame(build(), build()) + assertNotSame(build(), build()) + } +}